Compare commits
123
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d049d88dd7 | ||
|
|
f4b76fa272 | ||
|
|
564a649e6a | ||
|
|
7913d6a90f | ||
|
|
8b290a5908 | ||
|
|
cdf9793d6d | ||
|
|
29bcd2f6e9 | ||
|
|
b9d9fa7df8 | ||
|
|
d499d17271 | ||
|
|
2d3c041338 | ||
|
|
4e296dcdda | ||
|
|
d954d6fbcf | ||
|
|
e30de51ee9 | ||
|
|
285e9efb3f | ||
|
|
cad7944b92 | ||
|
|
407dfbb021 | ||
|
|
9a14540603 | ||
|
|
ae8930afa5 | ||
|
|
4178ab3c07 | ||
|
|
4c792865b4 | ||
|
|
a845177ebe | ||
|
|
eda1d516dc | ||
|
|
e8e5985ce6 | ||
|
|
d1d0ef6dbd | ||
|
|
87f5e1a25a | ||
|
|
b50bc13ef9 | ||
|
|
3fc4c63d38 | ||
|
|
61fec7689d | ||
|
|
201f7caed8 | ||
|
|
e0fa2cf972 | ||
|
|
70ae678af1 | ||
|
|
0dd373ec43 | ||
|
|
fbb3775770 | ||
|
|
cc5b9fb581 | ||
|
|
3de8e21683 | ||
|
|
04ea895ffb | ||
|
|
411f586c67 | ||
|
|
ca87c822ed | ||
|
|
5af8fa5c8c | ||
|
|
19f9be1dff | ||
|
|
3858f9419e | ||
|
|
01d7c87ecc | ||
|
|
362996e269 | ||
|
|
f54935738c | ||
|
|
f44f1f9615 | ||
|
|
663ba9a58f | ||
|
|
cb130bf776 | ||
|
|
8d302e37a8 | ||
|
|
2662bfb756 | ||
|
|
0da968e521 | ||
|
|
658947480a | ||
|
|
d2536a72bf | ||
|
|
5d253e65b7 | ||
|
|
10e43edc09 | ||
|
|
bff8ab0311 | ||
|
|
97a851bf97 | ||
|
|
25caaa4a70 | ||
|
|
0ad4f55aa8 | ||
|
|
2facea7f71 | ||
|
|
5b85a7d351 | ||
|
|
fd0796947f | ||
|
|
aa7bf329bc | ||
|
|
26787ce638 | ||
|
|
e23bb18dac | ||
|
|
45780edbbf | ||
|
|
39b0bc377c | ||
|
|
ca5bebef00 | ||
|
|
a696bceafa | ||
|
|
b3137d758c | ||
|
|
21e695fcb6 | ||
|
|
3c27efbb91 | ||
|
|
718e4e2e7e | ||
|
|
62a5d7207d | ||
|
|
ce0c3ae493 | ||
|
|
f73364b1c4 | ||
|
|
e7beaaf184 | ||
|
|
b06a06e608 | ||
|
|
828d3a320b | ||
|
|
4d363499db | ||
|
|
71c8ca17dc | ||
|
|
868bc1c242 | ||
|
|
6e42daf7dd | ||
|
|
1f1608067c | ||
|
|
52d9e57825 | ||
|
|
e464cde58f | ||
|
|
457128d4e8 | ||
|
|
2e20f6ae2d | ||
|
|
8f144fe36b | ||
|
|
4d7fc0f37c | ||
|
|
7fae87bc00 | ||
|
|
a7fb79efb2 | ||
|
|
502debed91 | ||
|
|
ffa65291d1 | ||
|
|
16233711d9 | ||
|
|
d69a0b2c29 | ||
|
|
763aadd6bf | ||
|
|
1f712173b2 | ||
|
|
dd2d1ba5e6 | ||
|
|
7966560fb5 | ||
|
|
113239f6e3 | ||
|
|
e6b05eaf63 | ||
|
|
289cc47631 | ||
|
|
51b44b6e3f | ||
|
|
a7780fe05f | ||
|
|
7d48a16f14 | ||
|
|
3c673468b4 | ||
|
|
95f2802f84 | ||
|
|
22ff6ca32b | ||
|
|
8dcab19d02 | ||
|
|
49fcad8cf8 | ||
|
|
24b5279f43 | ||
|
|
0ba451d004 | ||
|
|
4cc6da84a1 | ||
|
|
87e259a678 | ||
|
|
31f70d1f2a | ||
|
|
d05497f812 | ||
|
|
98a428fd61 | ||
|
|
8cce85b819 | ||
|
|
fc0f358f37 | ||
|
|
7a4da315a2 | ||
|
|
b978fd8b26 | ||
|
|
9fc9c15b4a | ||
|
|
fc7f55f490 |
@@ -398,3 +398,19 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Override STT provider endpoints (for proxies or self-hosted instances)
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT TEAMS INTEGRATION
|
||||
# =============================================================================
|
||||
# Register a Bot in Azure: https://dev.botframework.com/ → "Register a bot"
|
||||
# Or use Azure Portal: Azure Active Directory → App registrations → New registration
|
||||
# Then add the bot to Teams via the Bot Framework or App Studio.
|
||||
#
|
||||
# TEAMS_CLIENT_ID= # Azure AD App (client) ID
|
||||
# TEAMS_CLIENT_SECRET= # Azure AD client secret value
|
||||
# TEAMS_TENANT_ID= # Azure AD tenant ID (or "common" for multi-tenant)
|
||||
# TEAMS_ALLOWED_USERS= # Comma-separated AAD object IDs or UPNs
|
||||
# TEAMS_ALLOW_ALL_USERS=false # Set true to skip the allowlist
|
||||
# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery
|
||||
# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
name: 'Setup Nix'
|
||||
description: 'Install Nix with DeterminateSystems and enable magic-nix-cache'
|
||||
description: 'Install Nix and configure Cachix binary cache'
|
||||
|
||||
inputs:
|
||||
cachix-auth-token:
|
||||
description: 'Cachix auth token (enables push). Omit for read-only.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
|
||||
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
|
||||
with:
|
||||
name: hermes-agent
|
||||
authToken: ${{ inputs.cachix-auth-token }}
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Nix Lockfile Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-lockfile-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix-lockfile-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
|
||||
- name: Resolve head SHA
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check lockfile hashes
|
||||
id: check
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
- name: Fail if check crashed without reporting
|
||||
if: steps.check.outputs.stale != 'true' && steps.check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale)
|
||||
if: steps.check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles -- --apply` and commit the diff
|
||||
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: steps.check.outputs.stale == 'false' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Fail if stale
|
||||
if: steps.check.outputs.stale == 'true'
|
||||
run: exit 1
|
||||
@@ -28,7 +28,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles --apply and pushes the hash
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
@@ -62,6 +62,8 @@ jobs:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
@@ -200,10 +202,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
run: nix run .#fix-lockfiles
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
@@ -22,12 +23,95 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Resolve head SHA
|
||||
if: github.event_name == 'pull_request'
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
- name: Build package
|
||||
id: build
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix build --print-build-logs
|
||||
|
||||
# When the real Nix build fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
|
||||
# etc.) it won't set stale=true/false. Treat that as a distinct failure
|
||||
# mode rather than silently ignoring it.
|
||||
- name: Fail if hash check crashed without reporting
|
||||
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale hashes)
|
||||
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.hash_check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the build passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (build failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
runner.os == 'Linux' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if build or flake failed
|
||||
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix build/flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Evaluate flake (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: nix flake show --json > /dev/null
|
||||
|
||||
+3
-2
@@ -14,7 +14,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# that would otherwise accumulate when hermes runs as PID 1. See #15012.
|
||||
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 openssh-client docker-cli tini && \
|
||||
build-essential curl nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli tini && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
@@ -51,7 +51,8 @@ RUN cd web && npm run build && \
|
||||
cp -R packages/hermes-ink node_modules/@hermes/ink && \
|
||||
npm install --omit=dev --prefer-offline --no-audit --prefix node_modules/@hermes/ink && \
|
||||
rm -rf node_modules/@hermes/ink/node_modules/react && \
|
||||
node --input-type=module -e "await import('@hermes/ink')"
|
||||
node --input-type=module -e "await import('@hermes/ink')" && \
|
||||
touch .hermes-prebuilt-tui
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
|
||||
+144
-6
@@ -13,6 +13,7 @@ from typing import Any, Deque, Optional
|
||||
import acp
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AgentMessageChunk,
|
||||
AuthenticateResponse,
|
||||
AvailableCommand,
|
||||
AvailableCommandsUpdate,
|
||||
@@ -30,6 +31,7 @@ from acp.schema import (
|
||||
McpServerStdio,
|
||||
ModelInfo,
|
||||
NewSessionResponse,
|
||||
PromptCapabilities,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionResponse,
|
||||
@@ -45,6 +47,7 @@ from acp.schema import (
|
||||
TextContentBlock,
|
||||
UnstructuredCommandInput,
|
||||
Usage,
|
||||
UserMessageChunk,
|
||||
)
|
||||
|
||||
# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0
|
||||
@@ -88,17 +91,69 @@ def _extract_text(
|
||||
| EmbeddedResourceContentBlock
|
||||
],
|
||||
) -> str:
|
||||
"""Extract plain text from ACP content blocks."""
|
||||
"""Extract plain text from ACP content blocks for display/commands."""
|
||||
parts: list[str] = []
|
||||
for block in prompt:
|
||||
if isinstance(block, TextContentBlock):
|
||||
parts.append(block.text)
|
||||
elif hasattr(block, "text"):
|
||||
parts.append(str(block.text))
|
||||
# Non-text blocks are ignored for now.
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _image_block_to_openai_part(block: ImageContentBlock) -> dict[str, Any] | None:
|
||||
"""Convert an ACP image content block to OpenAI-style multimodal content."""
|
||||
data = str(getattr(block, "data", "") or "").strip()
|
||||
uri = str(getattr(block, "uri", "") or "").strip()
|
||||
mime_type = str(getattr(block, "mime_type", "") or "image/png").strip() or "image/png"
|
||||
|
||||
if data:
|
||||
url = data if data.startswith("data:") else f"data:{mime_type};base64,{data}"
|
||||
elif uri:
|
||||
url = uri
|
||||
else:
|
||||
return None
|
||||
|
||||
return {"type": "image_url", "image_url": {"url": url}}
|
||||
|
||||
|
||||
def _content_blocks_to_openai_user_content(
|
||||
prompt: list[
|
||||
TextContentBlock
|
||||
| ImageContentBlock
|
||||
| AudioContentBlock
|
||||
| ResourceContentBlock
|
||||
| EmbeddedResourceContentBlock
|
||||
],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Convert ACP prompt blocks into a Hermes/OpenAI-compatible user content payload."""
|
||||
parts: list[dict[str, Any]] = []
|
||||
text_parts: list[str] = []
|
||||
|
||||
for block in prompt:
|
||||
if isinstance(block, TextContentBlock):
|
||||
if block.text:
|
||||
parts.append({"type": "text", "text": block.text})
|
||||
text_parts.append(block.text)
|
||||
continue
|
||||
if isinstance(block, ImageContentBlock):
|
||||
image_part = _image_block_to_openai_part(block)
|
||||
if image_part is not None:
|
||||
parts.append(image_part)
|
||||
continue
|
||||
|
||||
if not parts:
|
||||
return _extract_text(prompt)
|
||||
|
||||
# Keep pure text prompts as strings so slash-command handling and text-only
|
||||
# providers keep the exact legacy path. Switch to structured content only
|
||||
# when an actual non-text block is present.
|
||||
if all(part.get("type") == "text" for part in parts):
|
||||
return "\n".join(text_parts)
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
class HermesACPAgent(acp.Agent):
|
||||
"""ACP Agent implementation wrapping Hermes AIAgent."""
|
||||
|
||||
@@ -352,6 +407,7 @@ class HermesACPAgent(acp.Agent):
|
||||
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
|
||||
agent_capabilities=AgentCapabilities(
|
||||
load_session=True,
|
||||
prompt_capabilities=PromptCapabilities(image=True),
|
||||
session_capabilities=SessionCapabilities(
|
||||
fork=SessionForkCapabilities(),
|
||||
list=SessionListCapabilities(),
|
||||
@@ -377,6 +433,78 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
# ---- Session management -------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _history_message_text(message: dict[str, Any]) -> str:
|
||||
"""Extract displayable text from a persisted OpenAI-style message."""
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif item.get("type") == "text" and isinstance(item.get("content"), str):
|
||||
parts.append(item["content"])
|
||||
elif isinstance(item, str):
|
||||
parts.append(item)
|
||||
return "\n".join(part.strip() for part in parts if part and part.strip()).strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _history_message_update(
|
||||
*,
|
||||
role: str,
|
||||
text: str,
|
||||
) -> UserMessageChunk | AgentMessageChunk | None:
|
||||
"""Build an ACP history replay update for a user/assistant message."""
|
||||
block = TextContentBlock(type="text", text=text)
|
||||
if role == "user":
|
||||
return UserMessageChunk(
|
||||
session_update="user_message_chunk",
|
||||
content=block,
|
||||
)
|
||||
if role == "assistant":
|
||||
return AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=block,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _replay_session_history(self, state: SessionState) -> None:
|
||||
"""Send persisted user/assistant history to clients during session/load.
|
||||
|
||||
Zed's ACP history UI calls ``session/load`` after the user picks an item
|
||||
from the Agents sidebar. The agent must then replay the full conversation
|
||||
as ``user_message_chunk`` / ``agent_message_chunk`` notifications; merely
|
||||
restoring server-side state makes Hermes remember context, but leaves the
|
||||
editor looking like a clean thread.
|
||||
"""
|
||||
if not self._conn or not state.history:
|
||||
return
|
||||
|
||||
for message in state.history:
|
||||
role = str(message.get("role") or "")
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
text = self._history_message_text(message)
|
||||
if not text:
|
||||
continue
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
if update is None:
|
||||
continue
|
||||
try:
|
||||
await self._conn.session_update(session_id=state.session_id, update=update)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to replay ACP history for session %s",
|
||||
state.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
async def new_session(
|
||||
self,
|
||||
cwd: str,
|
||||
@@ -405,6 +533,7 @@ class HermesACPAgent(acp.Agent):
|
||||
return None
|
||||
await self._register_session_mcp_servers(state, mcp_servers)
|
||||
logger.info("Loaded session %s", session_id)
|
||||
await self._replay_session_history(state)
|
||||
self._schedule_available_commands_update(session_id)
|
||||
return LoadSessionResponse(models=self._build_model_state(state))
|
||||
|
||||
@@ -421,6 +550,7 @@ class HermesACPAgent(acp.Agent):
|
||||
state = self.session_manager.create_session(cwd=cwd)
|
||||
await self._register_session_mcp_servers(state, mcp_servers)
|
||||
logger.info("Resumed session %s", state.session_id)
|
||||
await self._replay_session_history(state)
|
||||
self._schedule_available_commands_update(state.session_id)
|
||||
return ResumeSessionResponse(models=self._build_model_state(state))
|
||||
|
||||
@@ -517,11 +647,18 @@ class HermesACPAgent(acp.Agent):
|
||||
return PromptResponse(stop_reason="refusal")
|
||||
|
||||
user_text = _extract_text(prompt).strip()
|
||||
if not user_text:
|
||||
user_content = _content_blocks_to_openai_user_content(prompt)
|
||||
has_content = bool(user_text) or (
|
||||
isinstance(user_content, list) and bool(user_content)
|
||||
)
|
||||
if not has_content:
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# Intercept slash commands — handle locally without calling the LLM
|
||||
if user_text.startswith("/"):
|
||||
# Intercept slash commands — handle locally without calling the LLM.
|
||||
# Slash commands are text-only; if the client included images/resources,
|
||||
# send the whole multimodal prompt to the agent instead of treating it as
|
||||
# an ACP command.
|
||||
if isinstance(user_content, str) and user_text.startswith("/"):
|
||||
response_text = self._handle_slash_command(user_text, state)
|
||||
if response_text is not None:
|
||||
if self._conn:
|
||||
@@ -604,9 +741,10 @@ class HermesACPAgent(acp.Agent):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
try:
|
||||
result = agent.run_conversation(
|
||||
user_message=user_text,
|
||||
user_message=user_content,
|
||||
conversation_history=state.history,
|
||||
task_id=session_id,
|
||||
persist_user_message=user_text or "[Image attachment]",
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@@ -461,7 +461,11 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
|
||||
|
||||
|
||||
def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
def _common_betas_for_base_url(
|
||||
base_url: str | None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
) -> list[str]:
|
||||
"""Return the beta headers that are safe for the configured endpoint.
|
||||
|
||||
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
|
||||
@@ -472,14 +476,30 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
|
||||
endpoints — MiniMax hosts its own models, not Claude, so the header is
|
||||
irrelevant at best and risks request rejection at worst.
|
||||
|
||||
``drop_context_1m_beta=True`` additionally strips the 1M-context beta on
|
||||
otherwise-unrelated endpoints. The OAuth retry path flips this flag after
|
||||
a subscription rejects the beta with
|
||||
"The long context beta is not yet available for this subscription" so
|
||||
subsequent requests in the same session don't repeat the probe. See the
|
||||
reactive recovery loop in ``run_agent.py`` and issue-comment history on
|
||||
PR #17680 for the full rationale.
|
||||
"""
|
||||
if _requires_bearer_auth(base_url):
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
if drop_context_1m_beta:
|
||||
return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = None):
|
||||
def build_anthropic_client(
|
||||
api_key: str,
|
||||
base_url: str = None,
|
||||
timeout: float = None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
If *timeout* is provided it overrides the default 900s read timeout. The
|
||||
@@ -488,6 +508,12 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
Anthropic-compatible providers respect the same knob as OpenAI-wire
|
||||
providers.
|
||||
|
||||
``drop_context_1m_beta=True`` strips ``context-1m-2025-08-07`` from the
|
||||
client-level ``anthropic-beta`` header. Used by the reactive OAuth retry
|
||||
path in ``run_agent.py`` when a subscription rejects the beta; leave at
|
||||
its default on fresh clients so 1M-capable subscriptions keep the
|
||||
capability.
|
||||
|
||||
Returns an anthropic.Anthropic instance.
|
||||
"""
|
||||
_anthropic_sdk = _get_anthropic_sdk()
|
||||
@@ -517,7 +543,10 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
|
||||
kwargs["default_query"] = {"api-version": "2025-04-15"}
|
||||
else:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
common_betas = _common_betas_for_base_url(normalized_base_url)
|
||||
common_betas = _common_betas_for_base_url(
|
||||
normalized_base_url,
|
||||
drop_context_1m_beta=drop_context_1m_beta,
|
||||
)
|
||||
|
||||
if _is_kimi_coding_endpoint(base_url):
|
||||
# Kimi's /coding endpoint requires User-Agent: claude-code/0.1.0
|
||||
@@ -1689,6 +1718,7 @@ def build_anthropic_kwargs(
|
||||
context_length: Optional[int] = None,
|
||||
base_url: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
drop_context_1m_beta: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build kwargs for anthropic.messages.create().
|
||||
|
||||
@@ -1877,7 +1907,10 @@ def build_anthropic_kwargs(
|
||||
kwargs.setdefault("extra_body", {})["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))
|
||||
betas = list(_common_betas_for_base_url(
|
||||
base_url,
|
||||
drop_context_1m_beta=drop_context_1m_beta,
|
||||
))
|
||||
if is_oauth:
|
||||
betas.extend(_OAUTH_ONLY_BETAS)
|
||||
betas.append(_FAST_MODE_BETA)
|
||||
|
||||
+84
-39
@@ -5,11 +5,11 @@ session search, web extraction, vision analysis, browser vision) picks up
|
||||
the best available backend without duplicating fallback logic.
|
||||
|
||||
Resolution order for text tasks (auto mode):
|
||||
1. OpenRouter (OPENROUTER_API_KEY)
|
||||
2. Nous Portal (~/.hermes/auth.json active provider)
|
||||
3. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
|
||||
4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex,
|
||||
wrapped to look like a chat.completions client)
|
||||
1. User's main provider + main model (used regardless of provider type —
|
||||
aggregators, direct API-key providers, native Anthropic, Codex, etc.)
|
||||
2. OpenRouter (OPENROUTER_API_KEY)
|
||||
3. Nous Portal (~/.hermes/auth.json active provider)
|
||||
4. Custom endpoint (config.yaml model.base_url + OPENAI_API_KEY)
|
||||
5. Native Anthropic
|
||||
6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN)
|
||||
7. None
|
||||
@@ -18,10 +18,16 @@ Resolution order for vision/multimodal tasks (auto mode):
|
||||
1. Selected main provider, if it is one of the supported vision backends below
|
||||
2. OpenRouter
|
||||
3. Nous Portal
|
||||
4. Codex OAuth (gpt-5.3-codex supports vision via Responses API)
|
||||
5. Native Anthropic
|
||||
6. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
|
||||
7. None
|
||||
4. Native Anthropic
|
||||
5. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.)
|
||||
6. None
|
||||
|
||||
Codex OAuth (ChatGPT-account auth) is intentionally NOT in either
|
||||
fallback chain: OpenAI gates this endpoint behind an undocumented,
|
||||
shifting model allow-list, so "just try Codex with a hardcoded model"
|
||||
rots on its own. Codex is used only when the user's main provider *is*
|
||||
openai-codex (Step 1 above) or when a caller explicitly requests it with
|
||||
a model (auxiliary.<task>.provider + auxiliary.<task>.model).
|
||||
|
||||
Per-task overrides are configured in config.yaml under the ``auxiliary:`` section
|
||||
(e.g. ``auxiliary.vision.provider``, ``auxiliary.compression.model``).
|
||||
@@ -285,12 +291,14 @@ _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
|
||||
# Codex fallback: uses the Responses API (the only endpoint the Codex
|
||||
# OAuth token can access) with a fast model for auxiliary tasks.
|
||||
# ChatGPT-backed Codex accounts currently reject gpt-5.3-codex for these
|
||||
# auxiliary flows, while gpt-5.2-codex remains broadly available and supports
|
||||
# vision via Responses.
|
||||
_CODEX_AUX_MODEL = "gpt-5.2-codex"
|
||||
# Codex OAuth endpoint used when a caller explicitly requests
|
||||
# provider="openai-codex". There is deliberately no hardcoded default
|
||||
# model: the set of models OpenAI accepts on this endpoint for
|
||||
# ChatGPT-account auth is an undocumented, shifting allow-list, and
|
||||
# pinning one here has drifted silently twice (gpt-5.3-codex → gpt-5.2-codex
|
||||
# → gpt-5.4 over 6 weeks in early 2026). Callers must pass the model
|
||||
# they want explicitly (from config.yaml model.model, auxiliary.<task>.model,
|
||||
# or the user's active Codex model selection).
|
||||
_CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
@@ -347,6 +355,13 @@ def _to_openai_base_url(base_url: str) -> str:
|
||||
rewritten = url[: -len("/anthropic")] + "/v1"
|
||||
logger.debug("Auxiliary client: rewrote base URL %s → %s", url, rewritten)
|
||||
return rewritten
|
||||
if "api.kimi.com" in url and url.endswith("/coding"):
|
||||
# Kimi Code uses /coding/v1/messages for Anthropic SDK (appends /v1/messages)
|
||||
# but /coding/v1/chat/completions for OpenAI SDK (appends /chat/completions)
|
||||
# Without /v1 here, OpenAI SDK hits /coding/chat/completions — a 404.
|
||||
rewritten = url + "/v1"
|
||||
logger.debug("Auxiliary client: rewrote Kimi base URL %s → %s", url, rewritten)
|
||||
return rewritten
|
||||
return url
|
||||
|
||||
|
||||
@@ -1078,9 +1093,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if not api_key:
|
||||
continue
|
||||
|
||||
base_url = _to_openai_base_url(
|
||||
_pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
|
||||
)
|
||||
raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
|
||||
if model is None:
|
||||
continue # skip provider if we don't know a valid aux model
|
||||
@@ -1098,7 +1112,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider_id)
|
||||
@@ -1106,9 +1120,8 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if not api_key:
|
||||
continue
|
||||
|
||||
base_url = _to_openai_base_url(
|
||||
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
)
|
||||
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
|
||||
if model is None:
|
||||
continue # skip provider if we don't know a valid aux model
|
||||
@@ -1126,7 +1139,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, base_url)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
return None, None
|
||||
@@ -1420,7 +1433,23 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
return _fallback_client, model
|
||||
|
||||
|
||||
def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
||||
def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Build a CodexAuxiliaryClient for an explicitly-requested model.
|
||||
|
||||
There is no auto-selection of the Codex model: the ChatGPT-account
|
||||
Codex endpoint's accepted model list is an undocumented, drifting
|
||||
allow-list, so any hardcoded default we pick goes stale. The caller
|
||||
is responsible for passing the model (e.g. from the user's own
|
||||
``model.model`` or ``auxiliary.<task>.model`` config).
|
||||
|
||||
Returns (None, None) when no Codex OAuth token is available.
|
||||
"""
|
||||
if not model:
|
||||
logger.warning(
|
||||
"Auxiliary client: openai-codex requested without a model; "
|
||||
"pass model explicitly (auxiliary.<task>.model in config.yaml)."
|
||||
)
|
||||
return None, None
|
||||
pool_present, entry = _select_pool_entry("openai-codex")
|
||||
if pool_present:
|
||||
codex_token = _pool_runtime_api_key(entry)
|
||||
@@ -1436,13 +1465,13 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
||||
if not codex_token:
|
||||
return None, None
|
||||
base_url = _CODEX_AUX_BASE_URL
|
||||
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", _CODEX_AUX_MODEL)
|
||||
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", model)
|
||||
real_client = OpenAI(
|
||||
api_key=codex_token,
|
||||
base_url=base_url,
|
||||
default_headers=_codex_cloudflare_headers(codex_token),
|
||||
)
|
||||
return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
|
||||
|
||||
def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
|
||||
@@ -1497,7 +1526,6 @@ _AUTO_PROVIDER_LABELS = {
|
||||
"_try_openrouter": "openrouter",
|
||||
"_try_nous": "nous",
|
||||
"_try_custom_endpoint": "local/custom",
|
||||
"_try_codex": "openai-codex",
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
@@ -1524,12 +1552,18 @@ def _get_provider_chain() -> List[tuple]:
|
||||
|
||||
Built at call time (not module level) so that test patches
|
||||
on the ``_try_*`` functions are picked up correctly.
|
||||
|
||||
NOTE: ``openai-codex`` is deliberately NOT in this chain. The
|
||||
ChatGPT-account Codex endpoint only accepts a shifting, undocumented
|
||||
allow-list of model IDs, so falling back to it with a guessed model
|
||||
fails more often than not. Codex is used only when the user's main
|
||||
provider *is* openai-codex (see Step 1 of ``_resolve_auto``) or when
|
||||
a caller explicitly requests it with a model.
|
||||
"""
|
||||
return [
|
||||
("openrouter", _try_openrouter),
|
||||
("nous", _try_nous),
|
||||
("local/custom", _try_custom_endpoint),
|
||||
("openai-codex", _try_codex),
|
||||
("api-key", _resolve_api_key_provider),
|
||||
]
|
||||
|
||||
@@ -2045,6 +2079,13 @@ def resolve_provider_client(
|
||||
|
||||
# ── OpenAI Codex (OAuth → Responses API) ─────────────────────────
|
||||
if provider == "openai-codex":
|
||||
if not model:
|
||||
logger.warning(
|
||||
"resolve_provider_client: openai-codex requested without a "
|
||||
"model; pass model explicitly (e.g. model.model in config.yaml "
|
||||
"or auxiliary.<task>.model for per-task aux routing)."
|
||||
)
|
||||
return None, None
|
||||
if raw_codex:
|
||||
# Return the raw OpenAI client for callers that need direct
|
||||
# access to responses.stream() (e.g., the main agent loop).
|
||||
@@ -2053,7 +2094,7 @@ def resolve_provider_client(
|
||||
logger.warning("resolve_provider_client: openai-codex requested "
|
||||
"but no Codex OAuth token found (run: hermes model)")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or _CODEX_AUX_MODEL, provider)
|
||||
final_model = _normalize_resolved_model(model, provider)
|
||||
raw_client = OpenAI(
|
||||
api_key=codex_token,
|
||||
base_url=_CODEX_AUX_BASE_URL,
|
||||
@@ -2061,7 +2102,7 @@ def resolve_provider_client(
|
||||
)
|
||||
return (raw_client, final_model)
|
||||
# Standard path: wrap in CodexAuxiliaryClient adapter
|
||||
client, default = _try_codex()
|
||||
client, default = _build_codex_client(model)
|
||||
if client is None:
|
||||
logger.warning("resolve_provider_client: openai-codex requested "
|
||||
"but no Codex OAuth token found (run: hermes model)")
|
||||
@@ -2104,9 +2145,9 @@ def resolve_provider_client(
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
# Try custom first, then codex, then API-key providers
|
||||
for try_fn in (_try_custom_endpoint, _try_codex,
|
||||
_resolve_api_key_provider):
|
||||
# Try custom first, then API-key providers (Codex excluded here:
|
||||
# falling through to Codex with no model is a stale-constant trap).
|
||||
for try_fn in (_try_custom_endpoint, _resolve_api_key_provider):
|
||||
client, default = try_fn()
|
||||
if client is not None:
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
@@ -2148,8 +2189,10 @@ def resolve_provider_client(
|
||||
# Anthropic fallback SDK still sees the original URL.
|
||||
if entry_api_mode == "anthropic_messages":
|
||||
openai_base = custom_base
|
||||
raw_base_for_wrap = custom_base
|
||||
else:
|
||||
openai_base = _to_openai_base_url(custom_base)
|
||||
raw_base_for_wrap = custom_base
|
||||
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
|
||||
_extra2 = {"default_query": _dq2} if _dq2 else {}
|
||||
logger.debug(
|
||||
@@ -2193,7 +2236,7 @@ def resolve_provider_client(
|
||||
):
|
||||
client = CodexAuxiliaryClient(client, final_model)
|
||||
else:
|
||||
client = _wrap_if_needed(client, final_model, openai_base, custom_key)
|
||||
client = _wrap_if_needed(client, final_model, raw_base_for_wrap, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning(
|
||||
@@ -2239,9 +2282,8 @@ def resolve_provider_client(
|
||||
provider, ", ".join(tried_sources))
|
||||
return None, None
|
||||
|
||||
base_url = _to_openai_base_url(
|
||||
str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
)
|
||||
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
|
||||
base_url = _to_openai_base_url(raw_base_url)
|
||||
|
||||
default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
@@ -2290,7 +2332,7 @@ def resolve_provider_client(
|
||||
# Anthropic-wire endpoints (Kimi Coding Plan api.kimi.com/coding,
|
||||
# /anthropic-suffixed gateways) so named providers like kimi-coding
|
||||
# land on the right transport without needing per-provider branches.
|
||||
client = _wrap_if_needed(client, final_model, base_url, api_key)
|
||||
client = _wrap_if_needed(client, final_model, raw_base_url, api_key)
|
||||
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
@@ -2453,7 +2495,10 @@ def _resolve_strict_vision_backend(
|
||||
if provider == "nous":
|
||||
return _try_nous(vision=True)
|
||||
if provider == "openai-codex":
|
||||
return _try_codex()
|
||||
# Route through resolve_provider_client so the caller's explicit
|
||||
# model is used. There is no safe default Codex model (shifting
|
||||
# allow-list); callers must specify via auxiliary.<task>.model.
|
||||
return resolve_provider_client("openai-codex", model, is_vision=True)
|
||||
if provider == "anthropic":
|
||||
return _try_anthropic()
|
||||
if provider == "custom":
|
||||
|
||||
@@ -608,7 +608,7 @@ class CopilotACPClient:
|
||||
end = start + limit if isinstance(limit, int) and limit > 0 else None
|
||||
content = "".join(lines[start:end])
|
||||
if content:
|
||||
content = redact_sensitive_text(content)
|
||||
content = redact_sensitive_text(content, force=True)
|
||||
response = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": message_id,
|
||||
|
||||
+475
-29
@@ -7,7 +7,7 @@ daemon): when the agent is idle and the last curator run was longer than
|
||||
the review.
|
||||
|
||||
Responsibilities:
|
||||
- Auto-transition lifecycle states based on last_used_at timestamps
|
||||
- Auto-transition lifecycle states based on derived skill activity timestamps
|
||||
- Spawn a background review agent that can pin / archive / consolidate /
|
||||
patch agent-created skills via skill_manage
|
||||
- Persist curator state (last_run_at, paused, etc.) in .curator_state
|
||||
@@ -213,8 +213,8 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
||||
|
||||
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
"""Walk every agent-created skill and move active/stale/archived based on
|
||||
last_used_at. Pinned skills are never touched. Returns a counter dict
|
||||
describing what changed."""
|
||||
the latest real activity timestamp. Pinned skills are never touched.
|
||||
Returns a counter dict describing what changed."""
|
||||
from tools import skill_usage as _u
|
||||
|
||||
if now is None:
|
||||
@@ -230,10 +230,10 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
if row.get("pinned"):
|
||||
continue
|
||||
|
||||
last_used = _parse_iso(row.get("last_used_at"))
|
||||
# If never used, treat as using created_at as the anchor so new skills
|
||||
# don't immediately archive themselves.
|
||||
anchor = last_used or _parse_iso(row.get("created_at")) or now
|
||||
last_activity = _parse_iso(row.get("last_activity_at"))
|
||||
# If never active, treat created_at as the anchor so new skills don't
|
||||
# immediately archive themselves.
|
||||
anchor = last_activity or _parse_iso(row.get("created_at")) or now
|
||||
if anchor.tzinfo is None:
|
||||
anchor = anchor.replace(tzinfo=timezone.utc)
|
||||
|
||||
@@ -347,10 +347,27 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"cluster. If you end the pass with fewer than 10 archives, you "
|
||||
"stopped too early — go back and look at the clusters you left "
|
||||
"alone.\n\n"
|
||||
"When done, write a summary with: clusters processed, skills "
|
||||
"patched/absorbed, skills demoted to references/templates/scripts, "
|
||||
"skills archived, new umbrellas created, and clusters you "
|
||||
"deliberately left alone with one line each."
|
||||
"When done, write a human summary AND a structured machine-readable "
|
||||
"block so downstream tooling can distinguish consolidation from "
|
||||
"pruning. Format EXACTLY:\n\n"
|
||||
"## Structured summary (required)\n"
|
||||
"```yaml\n"
|
||||
"consolidations:\n"
|
||||
" - from: <old-skill-name>\n"
|
||||
" into: <umbrella-skill-name>\n"
|
||||
" reason: <one short sentence — why merged, not just 'similar'>\n"
|
||||
"prunings:\n"
|
||||
" - name: <skill-name>\n"
|
||||
" reason: <one short sentence — why archived with no merge target>\n"
|
||||
"```\n\n"
|
||||
"Every skill you moved to .archive/ MUST appear in exactly one of the "
|
||||
"two lists. If you consolidated X into umbrella Y (patched Y, wrote "
|
||||
"a references file to Y, or created Y with X's content absorbed), X "
|
||||
"goes under `consolidations` with `into: Y`. If you archived X with "
|
||||
"no absorption — truly stale, irrelevant, or obsolete — X goes under "
|
||||
"`prunings`. Leave a list empty (`consolidations: []`) if none. Do "
|
||||
"not omit the block. The block comes AFTER your human-readable "
|
||||
"summary of clusters processed, patches made, and decisions left alone."
|
||||
)
|
||||
|
||||
|
||||
@@ -365,8 +382,300 @@ def _reports_root() -> Path:
|
||||
alongside ``agent.log`` and ``gateway.log`` so it's found by anyone
|
||||
looking for operational telemetry, not mixed in with the user's
|
||||
authored skill data in ``~/.hermes/skills/``.
|
||||
|
||||
``ensure_hermes_home()`` pre-creates this dir on every CLI launch and
|
||||
the v22→v23 migration backfills it for existing profiles, but we
|
||||
still mkdir here as a belt-and-suspenders so the curator works even
|
||||
from an odd entry path (e.g. gateway-only install, bare library use)
|
||||
that bypasses both.
|
||||
"""
|
||||
return get_hermes_home() / "logs" / "curator"
|
||||
root = get_hermes_home() / "logs" / "curator"
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug("Curator reports dir create failed: %s", e)
|
||||
return root
|
||||
|
||||
|
||||
def _classify_removed_skills(
|
||||
removed: List[str],
|
||||
added: List[str],
|
||||
after_names: Set[str],
|
||||
tool_calls: List[Dict[str, Any]],
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Split ``removed`` into consolidated vs pruned.
|
||||
|
||||
A removed skill is "consolidated" when the curator absorbed its content
|
||||
into another skill (an umbrella) during this run — the content still
|
||||
lives, just under a different name. A removed skill is "pruned" when the
|
||||
curator archived it for staleness/irrelevance without preserving its
|
||||
content elsewhere.
|
||||
|
||||
Heuristic: scan this run's ``skill_manage`` tool calls and look for
|
||||
``write_file``/``patch``/``create``/``edit`` actions whose target skill
|
||||
(the ``name`` argument) is NOT the removed skill and whose
|
||||
``file_path`` / ``file_content`` / ``content`` arguments reference the
|
||||
removed skill's name. That's the textbook "absorbed into umbrella"
|
||||
signal. Ties are broken by first-match (earliest tool call wins).
|
||||
|
||||
Returns ``{"consolidated": [{"name", "into", "evidence"}, ...],
|
||||
"pruned": [{"name"}, ...]}``.
|
||||
"""
|
||||
consolidated: List[Dict[str, Any]] = []
|
||||
pruned: List[Dict[str, Any]] = []
|
||||
|
||||
# Pre-parse tool calls: we only care about skill_manage.
|
||||
parsed_calls: List[Dict[str, Any]] = []
|
||||
for tc in tool_calls or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
if tc.get("name") != "skill_manage":
|
||||
continue
|
||||
raw = tc.get("arguments") or ""
|
||||
# Arguments can be a JSON string (standard) or a dict (defensive).
|
||||
args: Dict[str, Any] = {}
|
||||
if isinstance(raw, dict):
|
||||
args = raw
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
args = json.loads(raw)
|
||||
except Exception:
|
||||
# Truncated or malformed — fall back to substring match on
|
||||
# the raw string so we still catch the common case.
|
||||
args = {"_raw": raw}
|
||||
if not isinstance(args, dict):
|
||||
continue
|
||||
parsed_calls.append(args)
|
||||
|
||||
# Build a set of "destination" skill names: anything still present after
|
||||
# the run plus anything newly added this run. A removed skill being
|
||||
# referenced from one of these is the consolidation signal.
|
||||
destinations = set(after_names) | set(added or [])
|
||||
|
||||
for name in removed:
|
||||
if not name:
|
||||
continue
|
||||
into: Optional[str] = None
|
||||
evidence: Optional[str] = None
|
||||
|
||||
# Normalise name variants we'll search for in path/content strings.
|
||||
needles = {name, name.replace("-", "_"), name.replace("_", "-")}
|
||||
|
||||
for args in parsed_calls:
|
||||
target = args.get("name")
|
||||
if not isinstance(target, str) or not target:
|
||||
continue
|
||||
# A call that operates on the removed skill itself isn't
|
||||
# consolidation evidence.
|
||||
if target == name:
|
||||
continue
|
||||
# The target must be a surviving or newly-created skill —
|
||||
# otherwise we're pointing to a skill that doesn't exist.
|
||||
if target not in destinations:
|
||||
continue
|
||||
|
||||
# Look for the removed skill's name in file_path / content / raw.
|
||||
haystacks: List[str] = []
|
||||
for key in ("file_path", "file_content", "content", "new_string", "_raw"):
|
||||
v = args.get(key)
|
||||
if isinstance(v, str):
|
||||
haystacks.append(v)
|
||||
hit = False
|
||||
for hay in haystacks:
|
||||
for needle in needles:
|
||||
if needle and needle in hay:
|
||||
hit = True
|
||||
evidence = (
|
||||
f"skill_manage action={args.get('action', '?')} "
|
||||
f"on '{target}' referenced '{name}' "
|
||||
f"in {hay[:80]}"
|
||||
)
|
||||
break
|
||||
if hit:
|
||||
break
|
||||
if hit:
|
||||
into = target
|
||||
break
|
||||
|
||||
if into:
|
||||
consolidated.append({"name": name, "into": into, "evidence": evidence})
|
||||
else:
|
||||
pruned.append({"name": name})
|
||||
|
||||
return {"consolidated": consolidated, "pruned": pruned}
|
||||
|
||||
|
||||
def _parse_structured_summary(
|
||||
llm_final: str,
|
||||
) -> Dict[str, List[Dict[str, str]]]:
|
||||
"""Extract the structured YAML block from the curator's final response.
|
||||
|
||||
The curator prompt requires a fenced ```yaml block under
|
||||
``## Structured summary (required)`` with ``consolidations:`` and
|
||||
``prunings:`` lists. This parses it tolerantly:
|
||||
|
||||
- Missing block → returns empty lists (we'll fall back to heuristic).
|
||||
- Malformed YAML → returns empty lists and we rely on heuristic.
|
||||
- Partial block (e.g. only consolidations) → returns what we could parse.
|
||||
|
||||
Returns ``{"consolidations": [{"from", "into", "reason"}, ...],
|
||||
"prunings": [{"name", "reason"}, ...]}``.
|
||||
"""
|
||||
empty = {"consolidations": [], "prunings": []}
|
||||
if not llm_final or not isinstance(llm_final, str):
|
||||
return empty
|
||||
|
||||
# Find the YAML fenced block. We look for ```yaml ... ``` specifically
|
||||
# rather than any fenced block so we don't accidentally pick up a code
|
||||
# sample the model quoted elsewhere.
|
||||
import re
|
||||
match = re.search(
|
||||
r"```ya?ml\s*\n(.*?)\n```",
|
||||
llm_final,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return empty
|
||||
|
||||
body = match.group(1)
|
||||
|
||||
# Prefer PyYAML when available — every hermes install already has it
|
||||
# (config.yaml loader). Fall back to a hand parser for paranoia.
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
data = yaml.safe_load(body)
|
||||
except Exception:
|
||||
return empty
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return empty
|
||||
|
||||
out: Dict[str, List[Dict[str, str]]] = {"consolidations": [], "prunings": []}
|
||||
cons_raw = data.get("consolidations") or []
|
||||
prun_raw = data.get("prunings") or []
|
||||
|
||||
if isinstance(cons_raw, list):
|
||||
for entry in cons_raw:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
frm = entry.get("from")
|
||||
into = entry.get("into")
|
||||
if not (isinstance(frm, str) and frm.strip()
|
||||
and isinstance(into, str) and into.strip()):
|
||||
continue
|
||||
reason = entry.get("reason")
|
||||
out["consolidations"].append({
|
||||
"from": frm.strip(),
|
||||
"into": into.strip(),
|
||||
"reason": (reason or "").strip() if isinstance(reason, str) else "",
|
||||
})
|
||||
|
||||
if isinstance(prun_raw, list):
|
||||
for entry in prun_raw:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not (isinstance(name, str) and name.strip()):
|
||||
continue
|
||||
reason = entry.get("reason")
|
||||
out["prunings"].append({
|
||||
"name": name.strip(),
|
||||
"reason": (reason or "").strip() if isinstance(reason, str) else "",
|
||||
})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _reconcile_classification(
|
||||
removed: List[str],
|
||||
heuristic: Dict[str, List[Dict[str, Any]]],
|
||||
model_block: Dict[str, List[Dict[str, str]]],
|
||||
destinations: Set[str],
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Merge heuristic (tool-call evidence) with the model's structured block.
|
||||
|
||||
Rules:
|
||||
- Model-declared consolidation wins when its ``into`` target exists
|
||||
in ``destinations`` (survived or newly-created). This gives the
|
||||
model authority over intent + rationale.
|
||||
- Model-declared consolidation whose ``into`` target does NOT exist is
|
||||
downgraded: the model hallucinated an umbrella. We prefer the
|
||||
heuristic's finding for that skill, or fall back to pruned.
|
||||
- Heuristic-only finding (model didn't mention it, tool calls confirm)
|
||||
is preserved as a consolidation, marked ``source="tool-call audit"``.
|
||||
- Model-declared pruning is accepted unless the heuristic has
|
||||
tool-call evidence that contradicts it (rare — the heuristic would
|
||||
have flagged consolidation). In that case we log both.
|
||||
|
||||
Every removed skill is placed in exactly one bucket.
|
||||
"""
|
||||
heur_cons = {e["name"]: e for e in heuristic.get("consolidated", [])}
|
||||
heur_pruned = {e["name"] for e in heuristic.get("pruned", [])}
|
||||
|
||||
model_cons = {e["from"]: e for e in model_block.get("consolidations", [])}
|
||||
model_pruned = {e["name"]: e for e in model_block.get("prunings", [])}
|
||||
|
||||
consolidated: List[Dict[str, Any]] = []
|
||||
pruned: List[Dict[str, Any]] = []
|
||||
|
||||
for name in removed:
|
||||
mc = model_cons.get(name)
|
||||
mp = model_pruned.get(name)
|
||||
hc = heur_cons.get(name)
|
||||
|
||||
# Model says consolidated — trust it if the destination is real.
|
||||
if mc and mc.get("into") in destinations:
|
||||
entry: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"into": mc["into"],
|
||||
"source": "model" + ("+audit" if hc else ""),
|
||||
"reason": mc.get("reason") or "",
|
||||
}
|
||||
if hc and hc.get("evidence"):
|
||||
entry["evidence"] = hc["evidence"]
|
||||
consolidated.append(entry)
|
||||
continue
|
||||
|
||||
# Model says consolidated but the umbrella doesn't exist —
|
||||
# hallucination. Fall back to heuristic or prune.
|
||||
if mc and mc.get("into") not in destinations:
|
||||
if hc:
|
||||
consolidated.append({
|
||||
"name": name,
|
||||
"into": hc["into"],
|
||||
"source": "tool-call audit (model named missing umbrella)",
|
||||
"reason": "",
|
||||
"evidence": hc.get("evidence", ""),
|
||||
"model_claimed_into": mc["into"],
|
||||
})
|
||||
else:
|
||||
pruned.append({
|
||||
"name": name,
|
||||
"source": "fallback (model named missing umbrella, no tool-call evidence)",
|
||||
"reason": "",
|
||||
})
|
||||
continue
|
||||
|
||||
# Heuristic found consolidation the model didn't mention.
|
||||
if hc:
|
||||
consolidated.append({
|
||||
"name": name,
|
||||
"into": hc["into"],
|
||||
"source": "tool-call audit (model omitted from structured block)",
|
||||
"reason": "",
|
||||
"evidence": hc.get("evidence", ""),
|
||||
})
|
||||
continue
|
||||
|
||||
# Model says pruned (or no mention + no heuristic evidence).
|
||||
reason = mp.get("reason", "") if mp else ""
|
||||
pruned.append({
|
||||
"name": name,
|
||||
"source": "model" if mp else "no-evidence fallback",
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
return {"consolidated": consolidated, "pruned": pruned}
|
||||
|
||||
|
||||
def _write_run_report(
|
||||
@@ -426,6 +735,37 @@ def _write_run_report(
|
||||
name = tc.get("name", "unknown")
|
||||
tc_counts[name] = tc_counts.get(name, 0) + 1
|
||||
|
||||
# Split "removed" into consolidated (absorbed into umbrella) vs pruned
|
||||
# (archived for staleness, content not preserved elsewhere). The old
|
||||
# "Skills archived" section lumped both together, which misled users
|
||||
# into thinking consolidated skills had been pruned.
|
||||
#
|
||||
# Classification strategy:
|
||||
# 1. Parse the curator's structured YAML block from its final response.
|
||||
# The curator is now prompted to emit consolidations/prunings lists
|
||||
# with short rationale. The model has intent visibility the tool
|
||||
# calls don't.
|
||||
# 2. Run the tool-call heuristic as a ground-truth audit.
|
||||
# 3. Reconcile: model gets authority over intent + rationale, heuristic
|
||||
# catches hallucination (umbrella doesn't exist) and omission
|
||||
# (model forgot to list an actual consolidation).
|
||||
heuristic = _classify_removed_skills(
|
||||
removed=removed,
|
||||
added=added,
|
||||
after_names=after_names,
|
||||
tool_calls=llm_meta.get("tool_calls", []) or [],
|
||||
)
|
||||
model_block = _parse_structured_summary(llm_meta.get("final", "") or "")
|
||||
destinations = set(after_names) | set(added or [])
|
||||
classification = _reconcile_classification(
|
||||
removed=removed,
|
||||
heuristic=heuristic,
|
||||
model_block=model_block,
|
||||
destinations=destinations,
|
||||
)
|
||||
consolidated = classification["consolidated"]
|
||||
pruned = classification["pruned"]
|
||||
|
||||
payload = {
|
||||
"started_at": started_at.isoformat(),
|
||||
"duration_seconds": round(elapsed_seconds, 2),
|
||||
@@ -438,11 +778,16 @@ def _write_run_report(
|
||||
"delta": len(after_names) - len(before_names),
|
||||
"archived_this_run": len(removed),
|
||||
"added_this_run": len(added),
|
||||
"consolidated_this_run": len(consolidated),
|
||||
"pruned_this_run": len(pruned),
|
||||
"state_transitions": len(transitions),
|
||||
"tool_calls_total": sum(tc_counts.values()),
|
||||
},
|
||||
"tool_call_counts": tc_counts,
|
||||
"archived": removed,
|
||||
"consolidated": consolidated,
|
||||
"pruned": pruned,
|
||||
"pruned_names": [p["name"] for p in pruned],
|
||||
"added": added,
|
||||
"state_transitions": transitions,
|
||||
"llm_final": llm_meta.get("final", ""),
|
||||
@@ -497,7 +842,7 @@ def _render_report_markdown(p: Dict[str, Any]) -> str:
|
||||
lines.append("## Auto-transitions (pure, no LLM)\n")
|
||||
lines.append(f"- checked: {auto.get('checked', 0)}")
|
||||
lines.append(f"- marked stale: {auto.get('marked_stale', 0)}")
|
||||
lines.append(f"- archived: {auto.get('archived', 0)}")
|
||||
lines.append(f"- archived (no LLM, pure time-based staleness): {auto.get('archived', 0)}")
|
||||
lines.append(f"- reactivated: {auto.get('reactivated', 0)}")
|
||||
lines.append("")
|
||||
|
||||
@@ -506,24 +851,78 @@ def _render_report_markdown(p: Dict[str, Any]) -> str:
|
||||
lines.append("## LLM consolidation pass\n")
|
||||
lines.append(f"- tool calls: **{counts.get('tool_calls_total', 0)}** "
|
||||
f"(by name: {', '.join(f'{k}={v}' for k, v in sorted(tc_counts.items())) or 'none'})")
|
||||
lines.append(f"- archived this run: **{counts.get('archived_this_run', 0)}**")
|
||||
lines.append(f"- consolidated into umbrellas: **{counts.get('consolidated_this_run', 0)}**")
|
||||
lines.append(f"- pruned (archived for staleness): **{counts.get('pruned_this_run', 0)}**")
|
||||
lines.append(f"- new skills this run: **{counts.get('added_this_run', 0)}**")
|
||||
lines.append(f"- state transitions (active ↔ stale ↔ archived): "
|
||||
f"**{counts.get('state_transitions', 0)}**")
|
||||
lines.append("")
|
||||
|
||||
# Archived list
|
||||
archived = p.get("archived") or []
|
||||
if archived:
|
||||
lines.append(f"### Skills archived ({len(archived)})\n")
|
||||
lines.append("_Archived skills are at `~/.hermes/skills/.archive/`. "
|
||||
"Restore any via `hermes curator restore <name>`._\n")
|
||||
# Show first 50 inline, note truncation after that
|
||||
# Consolidated list — content absorbed into an umbrella. The directory
|
||||
# on disk still lives under ~/.hermes/skills/.archive/ (every removal is
|
||||
# recoverable by design), but the "live" content for these skills
|
||||
# continues to exist inside the destination umbrella.
|
||||
consolidated = p.get("consolidated") or []
|
||||
if consolidated:
|
||||
lines.append(f"### Consolidated into umbrella skills ({len(consolidated)})\n")
|
||||
lines.append(
|
||||
"_These skills were **absorbed into another skill** during this run — "
|
||||
"their content still lives, just under a different name. "
|
||||
"The original directory was moved to `~/.hermes/skills/.archive/` for "
|
||||
"safety and can be restored via `hermes curator restore <name>` if the "
|
||||
"consolidation was wrong._\n"
|
||||
)
|
||||
SHOW = 50
|
||||
for n in archived[:SHOW]:
|
||||
lines.append(f"- `{n}`")
|
||||
if len(archived) > SHOW:
|
||||
lines.append(f"- … and {len(archived) - SHOW} more (see `run.json` for the full list)")
|
||||
for entry in consolidated[:SHOW]:
|
||||
name = entry.get("name", "?")
|
||||
into = entry.get("into", "?")
|
||||
reason = (entry.get("reason") or "").strip()
|
||||
source = entry.get("source", "")
|
||||
line = f"- `{name}` → merged into `{into}`"
|
||||
if reason:
|
||||
line += f" — {reason}"
|
||||
if source and source.startswith("tool-call audit"):
|
||||
# The model didn't enumerate this one — surface that to the
|
||||
# user so they know why the row has no rationale.
|
||||
line += f" _(detected via {source})_"
|
||||
lines.append(line)
|
||||
if entry.get("model_claimed_into"):
|
||||
lines.append(
|
||||
f" ⚠ The curator's summary named `{entry['model_claimed_into']}` "
|
||||
"as the umbrella but that skill doesn't exist post-run; "
|
||||
"showing the tool-call audit's finding instead."
|
||||
)
|
||||
if len(consolidated) > SHOW:
|
||||
lines.append(f"- … and {len(consolidated) - SHOW} more (see `run.json`)")
|
||||
lines.append("")
|
||||
|
||||
# Pruned list — archived without consolidation. These are the
|
||||
# "stale skill pruned" cases the UI should mark clearly.
|
||||
pruned = p.get("pruned") or []
|
||||
if pruned:
|
||||
lines.append(f"### Pruned — archived for staleness ({len(pruned)})\n")
|
||||
lines.append(
|
||||
"_These skills were archived without being merged into an umbrella "
|
||||
"(e.g. stale, unused, or judged irrelevant). "
|
||||
"Directories live under `~/.hermes/skills/.archive/`. "
|
||||
"Restore any via `hermes curator restore <name>`._\n"
|
||||
)
|
||||
SHOW = 50
|
||||
for entry in pruned[:SHOW]:
|
||||
# Entries are dicts with {name, source, reason} when written via
|
||||
# the reconciler, or bare strings when an older format slipped
|
||||
# through. Handle both.
|
||||
if isinstance(entry, dict):
|
||||
name = entry.get("name", "?")
|
||||
reason = (entry.get("reason") or "").strip()
|
||||
line = f"- `{name}`"
|
||||
if reason:
|
||||
line += f" — {reason}"
|
||||
lines.append(line)
|
||||
else:
|
||||
lines.append(f"- `{entry}`")
|
||||
if len(pruned) > SHOW:
|
||||
lines.append(f"- … and {len(pruned) - SHOW} more (see `run.json`)")
|
||||
lines.append("")
|
||||
|
||||
# Added list
|
||||
@@ -581,10 +980,11 @@ def _render_candidate_list() -> str:
|
||||
f"- {r['name']} "
|
||||
f"state={r['state']} "
|
||||
f"pinned={'yes' if r.get('pinned') else 'no'} "
|
||||
f"activity={r.get('activity_count', 0)} "
|
||||
f"use={r.get('use_count', 0)} "
|
||||
f"view={r.get('view_count', 0)} "
|
||||
f"patches={r.get('patch_count', 0)} "
|
||||
f"last_used={r.get('last_used_at') or 'never'}"
|
||||
f"last_activity={r.get('last_activity_at') or 'never'}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -714,6 +1114,49 @@ def run_curator_review(
|
||||
}
|
||||
|
||||
|
||||
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
|
||||
"""Pick (provider, model) for the curator review fork.
|
||||
|
||||
Curator is a regular auxiliary task slot — ``auxiliary.curator.{provider,model}``
|
||||
— so it participates in the canonical aux-model plumbing (``hermes model`` →
|
||||
auxiliary picker, the dashboard Models tab, ``auxiliary.curator.{timeout,
|
||||
base_url,api_key,extra_body}``). ``provider: "auto"`` with an empty model
|
||||
means "use the main chat model" — same default as every other aux task.
|
||||
|
||||
Legacy fallback: users who configured ``curator.auxiliary.{provider,model}``
|
||||
under the previous one-off schema still work. Precedence:
|
||||
1. ``auxiliary.curator.{provider,model}`` when both are set non-auto
|
||||
2. Legacy ``curator.auxiliary.{provider,model}`` when both are set
|
||||
3. Main ``model.{provider,default/model}`` pair
|
||||
"""
|
||||
_main = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
|
||||
_main_provider = _main.get("provider") or "auto"
|
||||
_main_model = _main.get("default") or _main.get("model") or ""
|
||||
|
||||
# 1. Canonical aux task slot
|
||||
_aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||||
_cur_task = _aux.get("curator", {}) if isinstance(_aux.get("curator"), dict) else {}
|
||||
_task_provider = (_cur_task.get("provider") or "").strip() or None
|
||||
_task_model = (_cur_task.get("model") or "").strip() or None
|
||||
if _task_provider and _task_provider != "auto" and _task_model:
|
||||
return _task_provider, _task_model
|
||||
|
||||
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
|
||||
_cur = cfg.get("curator", {}) if isinstance(cfg.get("curator"), dict) else {}
|
||||
_legacy = _cur.get("auxiliary", {}) if isinstance(_cur.get("auxiliary"), dict) else {}
|
||||
_legacy_provider = _legacy.get("provider") or None
|
||||
_legacy_model = _legacy.get("model") or None
|
||||
if _legacy_provider and _legacy_model:
|
||||
logger.info(
|
||||
"curator: using deprecated curator.auxiliary.{provider,model} "
|
||||
"config — please migrate to auxiliary.curator.{provider,model}"
|
||||
)
|
||||
return _legacy_provider, _legacy_model
|
||||
|
||||
# 3. Fall through to the main chat model
|
||||
return _main_provider, _main_model
|
||||
|
||||
|
||||
def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
"""Spawn an AIAgent fork to run the curator review prompt.
|
||||
|
||||
@@ -749,6 +1192,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
# "No models provided"). AIAgent() without explicit provider/model
|
||||
# arguments hits an auto-resolution path that fails for OAuth-only
|
||||
# providers and for pool-backed credentials.
|
||||
#
|
||||
# `_resolve_review_model()` honors `auxiliary.curator.{provider,model}`
|
||||
# (canonical aux-task slot, wired through `hermes model` → auxiliary
|
||||
# picker and the dashboard Models tab), with a legacy fallback to
|
||||
# `curator.auxiliary.{provider,model}`. See docs/user-guide/features/curator.md.
|
||||
_api_key = None
|
||||
_base_url = None
|
||||
_api_mode = None
|
||||
@@ -758,9 +1206,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
_cfg = load_config()
|
||||
_m = _cfg.get("model", {}) if isinstance(_cfg.get("model"), dict) else {}
|
||||
_provider = _m.get("provider") or "auto"
|
||||
_model_name = _m.get("default") or _m.get("model") or ""
|
||||
_provider, _model_name = _resolve_review_model(_cfg)
|
||||
_rp = resolve_runtime_provider(
|
||||
requested=_provider, target_model=_model_name
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ class FailoverReason(enum.Enum):
|
||||
# Provider-specific
|
||||
thinking_signature = "thinking_signature" # Anthropic thinking block sig invalid
|
||||
long_context_tier = "long_context_tier" # Anthropic "extra usage" tier gate
|
||||
oauth_long_context_beta_forbidden = "oauth_long_context_beta_forbidden" # Anthropic OAuth subscription rejects 1M context beta — disable beta and retry
|
||||
|
||||
# Catch-all
|
||||
unknown = "unknown" # Unclassifiable — retry with backoff
|
||||
@@ -450,6 +451,25 @@ def classify_api_error(
|
||||
should_compress=True,
|
||||
)
|
||||
|
||||
# Anthropic OAuth subscription rejects the 1M-context beta header.
|
||||
# Observed error body: "The long context beta is not yet available for
|
||||
# this subscription." Returned as HTTP 400 from native Anthropic when
|
||||
# the subscription doesn't include 1M context, even though the request
|
||||
# carries ``anthropic-beta: context-1m-2025-08-07``. The recovery path
|
||||
# in run_agent.py rebuilds the Anthropic client with the beta stripped
|
||||
# and retries once. Pattern is narrow enough that it won't collide with
|
||||
# the 429 tier-gate pattern above (different status, different phrase).
|
||||
if (
|
||||
status_code == 400
|
||||
and "long context beta" in error_msg
|
||||
and "not yet available" in error_msg
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.oauth_long_context_beta_forbidden,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# ── 2. HTTP status code classification ──────────────────────────
|
||||
|
||||
if status_code is not None:
|
||||
|
||||
@@ -1247,7 +1247,7 @@ def get_model_context_length(
|
||||
6. Nous suffix-match via OpenRouter cache
|
||||
7. models.dev registry lookup (provider-aware)
|
||||
8. Thin hardcoded defaults (broad family patterns)
|
||||
9. Default fallback (128K)
|
||||
9. Default fallback (256K)
|
||||
"""
|
||||
# 0. Explicit config override — user knows best
|
||||
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
|
||||
@@ -1427,7 +1427,7 @@ def get_model_context_length(
|
||||
save_context_length(model, base_url, local_ctx)
|
||||
return local_ctx
|
||||
|
||||
# 10. Default fallback — 128K
|
||||
# 10. Default fallback — 256K
|
||||
return DEFAULT_FALLBACK_CONTEXT
|
||||
|
||||
|
||||
|
||||
+4
-2
@@ -305,11 +305,13 @@ def _redact_form_body(text: str) -> str:
|
||||
return _redact_query_string(text.strip())
|
||||
|
||||
|
||||
def redact_sensitive_text(text: str) -> str:
|
||||
def redact_sensitive_text(text: str, *, force: bool = False) -> str:
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
@@ -317,7 +319,7 @@ def redact_sensitive_text(text: str) -> str:
|
||||
text = str(text)
|
||||
if not text:
|
||||
return text
|
||||
if not _REDACT_ENABLED:
|
||||
if not (force or _REDACT_ENABLED):
|
||||
return text
|
||||
|
||||
# Known prefixes (sk-, ghp_, etc.)
|
||||
|
||||
+82
-1
@@ -234,7 +234,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
for scan_dir in dirs_to_scan:
|
||||
for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"):
|
||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||
if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding='utf-8')
|
||||
@@ -284,6 +284,71 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
return _skill_commands
|
||||
|
||||
|
||||
def reload_skills() -> Dict[str, Any]:
|
||||
"""Re-scan the skills directory and return a diff of what changed.
|
||||
|
||||
Rescans ``~/.hermes/skills/`` and any ``skills.external_dirs`` so the
|
||||
slash-command map (``agent.skill_commands._skill_commands``) reflects
|
||||
skills added or removed on disk.
|
||||
|
||||
This does NOT invalidate the skills system-prompt cache. Skills are
|
||||
called by name via ``/skill-name``, ``skills_list``, or ``skill_view``
|
||||
— they don't need to be in the system prompt for the model to use them.
|
||||
Keeping the prompt cache intact preserves prefix caching across the
|
||||
reload, so a user invoking ``/reload-skills`` pays no cache-reset cost.
|
||||
|
||||
Returns:
|
||||
Dict with keys::
|
||||
|
||||
{
|
||||
"added": [{"name": str, "description": str}, ...],
|
||||
"removed": [{"name": str, "description": str}, ...],
|
||||
"unchanged": [skill names present before and after],
|
||||
"total": total skill count after rescan,
|
||||
"commands": total /slash-skill count after rescan,
|
||||
}
|
||||
|
||||
``description`` is the skill's full SKILL.md frontmatter
|
||||
``description:`` field — the same string the system prompt renders
|
||||
as `` - name: description`` for pre-existing skills.
|
||||
"""
|
||||
# Snapshot pre-reload state (name -> description) from the current
|
||||
# slash-command cache. Using dicts lets the post-rescan diff carry
|
||||
# descriptions for newly-visible or just-removed skills without a
|
||||
# second disk walk.
|
||||
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for slash_key, info in cmds.items():
|
||||
bare = slash_key.lstrip("/")
|
||||
out[bare] = (info or {}).get("description") or ""
|
||||
return out
|
||||
|
||||
before = _snapshot(_skill_commands)
|
||||
|
||||
# Rescan the skills dir. ``scan_skill_commands`` resets
|
||||
# ``_skill_commands = {}`` internally and repopulates it.
|
||||
new_commands = scan_skill_commands()
|
||||
|
||||
after = _snapshot(new_commands)
|
||||
|
||||
added_names = sorted(set(after) - set(before))
|
||||
removed_names = sorted(set(before) - set(after))
|
||||
unchanged = sorted(set(after) & set(before))
|
||||
|
||||
added = [{"name": n, "description": after[n]} for n in added_names]
|
||||
# For removed skills, use the description we had cached pre-rescan
|
||||
# (the skill file is gone so we can't re-read it).
|
||||
removed = [{"name": n, "description": before[n]} for n in removed_names]
|
||||
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"unchanged": unchanged,
|
||||
"total": len(after),
|
||||
"commands": len(new_commands),
|
||||
}
|
||||
|
||||
|
||||
def resolve_skill_command_key(command: str) -> Optional[str]:
|
||||
"""Resolve a user-typed /command to its canonical skill_cmds key.
|
||||
|
||||
@@ -328,6 +393,14 @@ def build_skill_invocation_message(
|
||||
return f"[Failed to load skill: {skill_info['name']}]"
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
# Track active usage for Curator lifecycle management (#17782)
|
||||
try:
|
||||
from tools.skill_usage import bump_use
|
||||
bump_use(skill_name)
|
||||
except Exception:
|
||||
pass # Non-critical — skill invocation proceeds regardless
|
||||
|
||||
activation_note = (
|
||||
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want '
|
||||
"you to follow its instructions. The full skill content is loaded below.]"
|
||||
@@ -367,6 +440,14 @@ def build_preloaded_skills_prompt(
|
||||
continue
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
|
||||
# Track active usage for Curator lifecycle management (#17782)
|
||||
try:
|
||||
from tools.skill_usage import bump_use
|
||||
bump_use(skill_name)
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
activation_note = (
|
||||
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
|
||||
"preloaded. Treat its instructions as active guidance for the duration of this "
|
||||
|
||||
@@ -24,7 +24,7 @@ PLATFORM_MAP = {
|
||||
"windows": "win32",
|
||||
}
|
||||
|
||||
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
|
||||
EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub", ".archive"))
|
||||
|
||||
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -440,7 +440,7 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
|
||||
def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
"""Walk skills_dir yielding sorted paths matching *filename*.
|
||||
|
||||
Excludes ``.git``, ``.github``, ``.hub`` directories.
|
||||
Excludes ``.git``, ``.github``, ``.hub``, ``.archive`` directories.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
|
||||
@@ -58,6 +58,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
context_length: int | None
|
||||
base_url: str | None
|
||||
fast_mode: bool
|
||||
drop_context_1m_beta: bool
|
||||
"""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
@@ -73,6 +74,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
context_length=params.get("context_length"),
|
||||
base_url=params.get("base_url"),
|
||||
fast_mode=params.get("fast_mode", False),
|
||||
drop_context_1m_beta=params.get("drop_context_1m_beta", False),
|
||||
)
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
|
||||
@@ -24,6 +24,18 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
||||
if reasoning_config is None or not isinstance(reasoning_config, dict):
|
||||
return None
|
||||
|
||||
normalized_model = (model or "").strip().lower()
|
||||
if normalized_model.startswith("google/"):
|
||||
normalized_model = normalized_model.split("/", 1)[1]
|
||||
|
||||
# ``thinking_config`` is a Gemini-only request parameter. The same
|
||||
# ``gemini`` provider also serves Gemma (and historically PaLM/Bard);
|
||||
# those reject the field with HTTP 400 "Unknown name 'thinking_config':
|
||||
# Cannot find field" — including the polite ``{"includeThoughts": False}``
|
||||
# form. Omit the field entirely on non-Gemini models. (#17426)
|
||||
if not normalized_model.startswith("gemini"):
|
||||
return None
|
||||
|
||||
if reasoning_config.get("enabled") is False:
|
||||
# Gemini can hide thought parts even when internal thinking still
|
||||
# happens; omit thinkingLevel to avoid model-specific validation quirks.
|
||||
@@ -34,9 +46,6 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
|
||||
return {"includeThoughts": False}
|
||||
|
||||
thinking_config: Dict[str, Any] = {"includeThoughts": True}
|
||||
normalized_model = (model or "").strip().lower()
|
||||
if normalized_model.startswith("google/"):
|
||||
normalized_model = normalized_model.split("/", 1)[1]
|
||||
|
||||
# Gemini 2.5 accepts thinkingBudget; don't guess a budget from Hermes'
|
||||
# coarse effort levels. ``includeThoughts`` alone is enough to surface
|
||||
|
||||
@@ -570,7 +570,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, qqbot, teams
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
@@ -600,6 +600,7 @@ agent:
|
||||
# signal: hermes-signal (same as telegram)
|
||||
# homeassistant: hermes-homeassistant (same as telegram)
|
||||
# qqbot: hermes-qqbot (same as telegram)
|
||||
# teams: hermes-teams (same as telegram)
|
||||
#
|
||||
platform_toolsets:
|
||||
cli: [hermes-cli]
|
||||
@@ -611,6 +612,7 @@ platform_toolsets:
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
teams: [hermes-teams]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
|
||||
@@ -1540,9 +1540,29 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
|
||||
# that appears when the ESC byte was stripped by a prior filter.
|
||||
_DSR_CPR_ESC_RE = re.compile(r"\x1b\[\d+;\d+R")
|
||||
_DSR_CPR_VISIBLE_RE = re.compile(r"\^\[\[\d+;\d+R")
|
||||
_SGR_MOUSE_ESC_RE = re.compile(r"\x1b\[<\d+;\d+;\d+[Mm]")
|
||||
_SGR_MOUSE_VISIBLE_RE = re.compile(r"\^\[\[<\d+;\d+;\d+[Mm]")
|
||||
# Some terminals/filters can drop ESC and literal "^[[", leaving only
|
||||
# "<btn;col;rowM" fragments in the buffer. Keep this broad on purpose:
|
||||
# these fragments are extremely unlikely to be intentional user input, and
|
||||
# stripping them is better than sending corrupted prompts.
|
||||
_SGR_MOUSE_BARE_RE = re.compile(r"<\d+;\d+;\d+[Mm]")
|
||||
_TERMINAL_INPUT_MODE_RESET_SEQ = (
|
||||
"\x1b[?1006l" # disable SGR mouse
|
||||
"\x1b[?1003l" # disable any-motion tracking
|
||||
"\x1b[?1002l" # disable button-motion tracking
|
||||
"\x1b[?1000l" # disable click tracking
|
||||
"\x1b[?1004l" # disable focus events
|
||||
"\x1b[?2004l" # disable bracketed paste
|
||||
"\x1b[?1049l" # leave alt screen (if stuck there)
|
||||
"\x1b[<u" # pop kitty keyboard mode
|
||||
"\x1b[>4m" # reset modifyOtherKeys
|
||||
"\x1b[0m" # reset text attributes
|
||||
"\x1b[?25h" # ensure cursor visible
|
||||
)
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
|
||||
"""Strip leaked terminal control-response sequences from user input.
|
||||
|
||||
Covers Cursor Position Report (CPR / DSR) responses — ``ESC[<row>;<col>R``
|
||||
@@ -1552,12 +1572,43 @@ def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
(resize storms, multiplexer focus changes, slow PTYs) the response
|
||||
lands in the input buffer as literal text and corrupts what the user
|
||||
typed.
|
||||
|
||||
Also strips leaked SGR mouse-report fragments (``ESC[<...M/m`` and
|
||||
degraded visible forms). Returns ``(cleaned_text, had_mouse_reports)``
|
||||
so callers can trigger an in-place terminal mode recovery when needed.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
text = _DSR_CPR_ESC_RE.sub("", text)
|
||||
text = _DSR_CPR_VISIBLE_RE.sub("", text)
|
||||
return text
|
||||
return text, False
|
||||
|
||||
has_esc = "\x1b[" in text
|
||||
has_visible = "^[" in text
|
||||
has_bare_mouse = "<" in text and ";" in text and ("M" in text or "m" in text)
|
||||
if not (has_esc or has_visible or has_bare_mouse):
|
||||
return text, False
|
||||
|
||||
had_mouse_reports = False
|
||||
|
||||
if has_esc:
|
||||
text = _DSR_CPR_ESC_RE.sub("", text)
|
||||
text, count = _SGR_MOUSE_ESC_RE.subn("", text)
|
||||
had_mouse_reports = had_mouse_reports or count > 0
|
||||
|
||||
if has_visible:
|
||||
text = _DSR_CPR_VISIBLE_RE.sub("", text)
|
||||
text, count = _SGR_MOUSE_VISIBLE_RE.subn("", text)
|
||||
had_mouse_reports = had_mouse_reports or count > 0
|
||||
|
||||
if has_bare_mouse:
|
||||
text, count = _SGR_MOUSE_BARE_RE.subn("", text)
|
||||
had_mouse_reports = had_mouse_reports or count > 0
|
||||
|
||||
return text, had_mouse_reports
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
"""Compatibility wrapper returning only cleaned text."""
|
||||
cleaned, _ = _strip_leaked_terminal_responses_with_meta(text)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
@@ -1931,6 +1982,8 @@ class HermesCLI:
|
||||
self._stream_box_opened = False # True once the response box header is printed
|
||||
self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output
|
||||
self._pending_edit_snapshots = {}
|
||||
self._last_input_mode_recovery = 0.0
|
||||
self._input_mode_recovery_notice_shown = False
|
||||
|
||||
# Configuration - priority: CLI args > env vars > config file
|
||||
# Model comes from: CLI arg or config.yaml (single source of truth).
|
||||
@@ -2119,11 +2172,6 @@ class HermesCLI:
|
||||
self._pending_input = queue.Queue()
|
||||
self._interrupt_queue = queue.Queue()
|
||||
self._should_exit = False
|
||||
# /exit --delete: when True, the current session's SQLite history and
|
||||
# on-disk transcripts are deleted during shutdown. Set by
|
||||
# process_command() when the user runs /exit --delete or /quit --delete.
|
||||
# Ported from google-gemini/gemini-cli#19332.
|
||||
self._delete_session_on_exit = False
|
||||
self._last_ctrl_c_time = 0
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
@@ -3112,6 +3160,8 @@ class HermesCLI:
|
||||
return "Processing skills command..."
|
||||
if cmd_lower == "/reload-mcp":
|
||||
return "Reloading MCP servers..."
|
||||
if cmd_lower == "/reload-skills" or cmd_lower == "/reload_skills":
|
||||
return "Reloading skills..."
|
||||
if cmd_lower.startswith("/browser"):
|
||||
return "Configuring browser..."
|
||||
return "Processing command..."
|
||||
@@ -4125,6 +4175,37 @@ class HermesCLI:
|
||||
sys.stdout.write(seq)
|
||||
sys.stdout.flush()
|
||||
|
||||
def _recover_terminal_input_modes(self, *, reason: str) -> None:
|
||||
"""Best-effort reset when leaked mouse reports indicate mode drift."""
|
||||
now = time.monotonic()
|
||||
# Rate-limit to avoid thrashing if a terminal floods reports.
|
||||
if now - self._last_input_mode_recovery < 0.5:
|
||||
return
|
||||
self._last_input_mode_recovery = now
|
||||
|
||||
out = getattr(self, "_app", None)
|
||||
output = getattr(out, "output", None) if out else None
|
||||
try:
|
||||
if output and hasattr(output, "write_raw"):
|
||||
output.write_raw(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
output.flush()
|
||||
elif output and hasattr(output, "write"):
|
||||
output.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
output.flush()
|
||||
else:
|
||||
sys.stdout.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
logger.warning("Recovered terminal input modes after leak: %s", reason)
|
||||
if not self._input_mode_recovery_notice_shown:
|
||||
self._input_mode_recovery_notice_shown = True
|
||||
_cprint(
|
||||
f" {_DIM}Recovered terminal input modes after leaked mouse reports. "
|
||||
f"If this repeats, run /new or restart this tab.{_RST}"
|
||||
)
|
||||
|
||||
def _handle_copy_command(self, cmd_original: str) -> None:
|
||||
"""Handle /copy [number] — copy assistant output to clipboard."""
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
@@ -5331,6 +5412,7 @@ class HermesCLI:
|
||||
base_url=result.base_url or self.base_url or "",
|
||||
api_key=result.api_key or self.api_key or "",
|
||||
model_info=mi,
|
||||
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None,
|
||||
)
|
||||
if ctx:
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
@@ -5557,6 +5639,7 @@ class HermesCLI:
|
||||
base_url=result.base_url or self.base_url or "",
|
||||
api_key=result.api_key or self.api_key or "",
|
||||
model_info=mi,
|
||||
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None,
|
||||
)
|
||||
if ctx:
|
||||
_cprint(f" Context: {ctx:,} tokens")
|
||||
@@ -6088,16 +6171,6 @@ class HermesCLI:
|
||||
canonical = _cmd_def.name if _cmd_def else _base_word
|
||||
|
||||
if canonical in ("quit", "exit", "q"):
|
||||
# Parse --delete flag: /exit --delete also removes the current
|
||||
# session's transcripts + SQLite history. Ported from
|
||||
# google-gemini/gemini-cli#19332.
|
||||
_rest = cmd_original.split(None, 1)
|
||||
_args = (_rest[1] if len(_rest) > 1 else "").strip().lower()
|
||||
if _args in ("--delete", "-d"):
|
||||
self._delete_session_on_exit = True
|
||||
elif _args:
|
||||
_cprint(f" {_DIM}✗ Unknown argument: {_escape(_args)}. Use /exit --delete to also remove session history.{_RST}")
|
||||
return True
|
||||
return False
|
||||
elif canonical == "help":
|
||||
self.show_help()
|
||||
@@ -6299,8 +6372,13 @@ class HermesCLI:
|
||||
count = reload_env()
|
||||
print(f" Reloaded .env ({count} var(s) updated)")
|
||||
elif canonical == "reload-mcp":
|
||||
# Interactive reload: confirm first (unless the user has opted out).
|
||||
# The auto-reload path (file watcher) calls _reload_mcp directly
|
||||
# without this confirmation.
|
||||
self._confirm_and_reload_mcp(cmd_original)
|
||||
elif canonical == "reload-skills":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
self._reload_skills()
|
||||
elif canonical == "browser":
|
||||
self._handle_browser_command(cmd_original)
|
||||
elif canonical == "plugins":
|
||||
@@ -7427,6 +7505,77 @@ class HermesCLI:
|
||||
if _reload_thread.is_alive():
|
||||
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
|
||||
|
||||
def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
|
||||
"""Interactive /reload-mcp — confirm with the user, then reload.
|
||||
|
||||
Reloading MCP tools invalidates the provider prompt cache for the
|
||||
active session (tool schemas are baked into the system prompt).
|
||||
The next message re-sends full input tokens — can be expensive on
|
||||
long-context or high-reasoning models.
|
||||
|
||||
Three options: Approve Once, Always Approve (persists
|
||||
``approvals.mcp_reload_confirm: false`` so future reloads run
|
||||
without this prompt), Cancel. Gated by
|
||||
``approvals.mcp_reload_confirm`` — default on.
|
||||
"""
|
||||
# Gate check — respects prior "Always Approve" clicks.
|
||||
try:
|
||||
cfg = load_cli_config()
|
||||
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
|
||||
confirm_required = True
|
||||
if isinstance(approvals, dict):
|
||||
confirm_required = bool(approvals.get("mcp_reload_confirm", True))
|
||||
except Exception:
|
||||
confirm_required = True
|
||||
|
||||
if not confirm_required:
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
return
|
||||
|
||||
# Render warning + prompt. Use a single-line prompt so the user
|
||||
# sees the warning as output and types a response into the composer.
|
||||
print()
|
||||
print("⚠️ /reload-mcp — Prompt cache invalidation warning")
|
||||
print()
|
||||
print(" Reloading MCP servers rebuilds the tool set for this session and")
|
||||
print(" invalidates the provider prompt cache. The next message will")
|
||||
print(" re-send full input tokens (can be expensive on long-context or")
|
||||
print(" high-reasoning models).")
|
||||
print()
|
||||
print(" [1] Approve Once — reload now")
|
||||
print(" [2] Always Approve — reload now and silence this prompt permanently")
|
||||
print(" [3] Cancel — leave MCP tools unchanged")
|
||||
print()
|
||||
raw = self._prompt_text_input("Choice [1/2/3]: ")
|
||||
if raw is None:
|
||||
print("🟡 /reload-mcp cancelled (no input).")
|
||||
return
|
||||
choice_raw = raw.strip().lower()
|
||||
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
|
||||
choice = "once"
|
||||
elif choice_raw in ("2", "always", "remember"):
|
||||
choice = "always"
|
||||
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
|
||||
choice = "cancel"
|
||||
else:
|
||||
print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
|
||||
return
|
||||
|
||||
if choice == "cancel":
|
||||
print("🟡 /reload-mcp cancelled. MCP tools unchanged.")
|
||||
return
|
||||
|
||||
if choice == "always":
|
||||
if save_config_value("approvals.mcp_reload_confirm", False):
|
||||
print("🔒 Future /reload-mcp calls will run without confirmation.")
|
||||
print(" Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml.")
|
||||
else:
|
||||
print("⚠️ Couldn't persist opt-out — reloading once.")
|
||||
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
|
||||
def _reload_mcp(self):
|
||||
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
|
||||
|
||||
@@ -7512,6 +7661,78 @@ class HermesCLI:
|
||||
except Exception as e:
|
||||
print(f" ❌ MCP reload failed: {e}")
|
||||
|
||||
def _reload_skills(self) -> None:
|
||||
"""Reload skills: rescan ~/.hermes/skills/ and queue a note for the
|
||||
next user turn.
|
||||
|
||||
Skills don't need to live in the system prompt for the model to use
|
||||
them (they're invoked via ``/skill-name``, ``skills_list``, or
|
||||
``skill_view`` at runtime), so this does NOT clear the prompt cache.
|
||||
It rescans the slash-command map, prints the diff for the user, and
|
||||
— if any skills were added or removed — queues a one-shot note that
|
||||
gets prepended to the next user message. This preserves message
|
||||
alternation (no phantom user turn injected out of band) and keeps
|
||||
prompt caching intact.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_commands import reload_skills
|
||||
|
||||
if not self._command_running:
|
||||
print("🔄 Reloading skills...")
|
||||
|
||||
result = reload_skills()
|
||||
added = result.get("added", []) # [{"name", "description"}, ...]
|
||||
removed = result.get("removed", []) # [{"name", "description"}, ...]
|
||||
total = result.get("total", 0)
|
||||
|
||||
if not added and not removed:
|
||||
print(" No new skills detected.")
|
||||
print(f" 📚 {total} skill(s) available")
|
||||
return
|
||||
|
||||
def _fmt_line(item: dict) -> str:
|
||||
nm = item.get("name", "")
|
||||
desc = item.get("description", "")
|
||||
return f" - {nm}: {desc}" if desc else f" - {nm}"
|
||||
|
||||
if added:
|
||||
print(" ➕ Added Skills:")
|
||||
for item in added:
|
||||
print(f" {_fmt_line(item)}")
|
||||
if removed:
|
||||
print(" ➖ Removed Skills:")
|
||||
for item in removed:
|
||||
print(f" {_fmt_line(item)}")
|
||||
print(f" 📚 {total} skill(s) available")
|
||||
|
||||
# Queue a one-shot note for the NEXT user turn. The CLI's agent
|
||||
# loop prepends ``_pending_skills_reload_note`` (if set) to the
|
||||
# API-call-local message at ~L8770, then clears it — same
|
||||
# pattern as ``_pending_model_switch_note``. Nothing is written
|
||||
# to conversation_history here, so message alternation stays
|
||||
# intact and no out-of-band user turn is persisted.
|
||||
#
|
||||
# Format matches how the system prompt renders pre-existing
|
||||
# skills (`` - name: description``) so the model reads the
|
||||
# diff in the same shape as its original skill catalog.
|
||||
sections = ["[USER INITIATED SKILLS RELOAD:"]
|
||||
if added:
|
||||
sections.append("")
|
||||
sections.append("Added Skills:")
|
||||
for item in added:
|
||||
sections.append(_fmt_line(item))
|
||||
if removed:
|
||||
sections.append("")
|
||||
sections.append("Removed Skills:")
|
||||
for item in removed:
|
||||
sections.append(_fmt_line(item))
|
||||
sections.append("")
|
||||
sections.append("Use skills_list to see the updated catalog.]")
|
||||
self._pending_skills_reload_note = "\n".join(sections)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Skills reload failed: {e}")
|
||||
|
||||
# ====================================================================
|
||||
# Tool-call generation indicator (shown during streaming)
|
||||
# ====================================================================
|
||||
@@ -8593,7 +8814,8 @@ class HermesCLI:
|
||||
from agent.context_references import preprocess_context_references
|
||||
from agent.model_metadata import get_model_context_length
|
||||
_ctx_len = get_model_context_length(
|
||||
self.model, base_url=self.base_url or "", api_key=self.api_key or "")
|
||||
self.model, base_url=self.base_url or "", api_key=self.api_key or "",
|
||||
config_context_length=getattr(self.agent, "_config_context_length", None) if self.agent else None)
|
||||
_ctx_result = preprocess_context_references(
|
||||
message, cwd=os.getcwd(), context_length=_ctx_len)
|
||||
if _ctx_result.expanded or _ctx_result.blocked:
|
||||
@@ -8720,6 +8942,13 @@ class HermesCLI:
|
||||
if _msn:
|
||||
agent_message = _msn + "\n\n" + agent_message
|
||||
self._pending_model_switch_note = None
|
||||
# Prepend pending /reload-skills note so the model sees which
|
||||
# skills were added/removed before handling this turn. Same
|
||||
# one-shot queue pattern as the model-switch note above.
|
||||
_srn = getattr(self, '_pending_skills_reload_note', None)
|
||||
if _srn:
|
||||
agent_message = _srn + "\n\n" + agent_message
|
||||
self._pending_skills_reload_note = None
|
||||
try:
|
||||
result = self.agent.run_conversation(
|
||||
user_message=agent_message,
|
||||
@@ -10050,7 +10279,9 @@ class HermesCLI:
|
||||
# so the 5-line collapse threshold and display are consistent.
|
||||
pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
|
||||
pasted_text = _strip_leaked_terminal_responses(pasted_text)
|
||||
pasted_text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(pasted_text)
|
||||
if _had_mouse_reports:
|
||||
self._recover_terminal_input_modes(reason="mouse reports leaked into bracketed paste payload")
|
||||
if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
if pasted_text:
|
||||
@@ -10204,7 +10435,9 @@ class HermesCLI:
|
||||
event so it never triggers this.
|
||||
"""
|
||||
text = _strip_leaked_bracketed_paste_wrappers(buf.text)
|
||||
text = _strip_leaked_terminal_responses(text)
|
||||
text, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(text)
|
||||
if _had_mouse_reports:
|
||||
self._recover_terminal_input_modes(reason="mouse reports leaked into prompt buffer")
|
||||
if text != buf.text:
|
||||
cursor = min(buf.cursor_position, len(text))
|
||||
_paste_just_collapsed[0] = True
|
||||
@@ -10957,7 +11190,9 @@ class HermesCLI:
|
||||
|
||||
if isinstance(user_input, str):
|
||||
user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
|
||||
user_input = _strip_leaked_terminal_responses(user_input)
|
||||
user_input, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(user_input)
|
||||
if _had_mouse_reports:
|
||||
self._recover_terminal_input_modes(reason="mouse reports leaked into submitted input")
|
||||
|
||||
# Check for commands — but detect dragged/pasted file paths first.
|
||||
# See _detect_file_drop() for details.
|
||||
@@ -11189,19 +11424,6 @@ class HermesCLI:
|
||||
self._session_db.end_session(self.agent.session_id, "cli_close")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not close session in DB: %s", e)
|
||||
# /exit --delete: also remove the current session's transcripts
|
||||
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
|
||||
if getattr(self, '_delete_session_on_exit', False):
|
||||
try:
|
||||
from hermes_constants import get_hermes_home as _ghh
|
||||
_sessions_dir = _ghh() / "sessions"
|
||||
_sid = self.agent.session_id
|
||||
if self._session_db.delete_session(_sid, sessions_dir=_sessions_dir):
|
||||
_cprint(f" {_DIM}✓ Session {_escape(_sid)} deleted{_RST}")
|
||||
else:
|
||||
_cprint(f" {_DIM}✗ Session {_escape(_sid)} not found for deletion{_RST}")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not delete session on exit: %s", e)
|
||||
# Plugin hook: on_session_end — safety net for interrupted exits.
|
||||
# run_conversation() already fires this per-turn on normal completion,
|
||||
# so only fire here if the agent was mid-turn (_agent_running) when
|
||||
|
||||
+65
-37
@@ -277,13 +277,21 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
|
||||
return targets[0] if targets else None
|
||||
|
||||
|
||||
# Media extension sets — keep in sync with gateway/platforms/base.py:_process_message_background
|
||||
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a'})
|
||||
# Media extension sets — audio routing is centralized in gateway.platforms.base
|
||||
# via should_send_media_as_audio() so Telegram-specific rules stay in one place.
|
||||
_VIDEO_EXTS = frozenset({'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'})
|
||||
_IMAGE_EXTS = frozenset({'.jpg', '.jpeg', '.png', '.webp', '.gif'})
|
||||
|
||||
|
||||
def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata: dict | None, loop, job: dict) -> None:
|
||||
def _send_media_via_adapter(
|
||||
adapter,
|
||||
chat_id: str,
|
||||
media_files: list,
|
||||
metadata: dict | None,
|
||||
loop,
|
||||
job: dict,
|
||||
platform=None,
|
||||
) -> None:
|
||||
"""Send extracted MEDIA files as native platform attachments via a live adapter.
|
||||
|
||||
Routes each file to the appropriate adapter method (send_voice, send_image_file,
|
||||
@@ -292,10 +300,13 @@ def _send_media_via_adapter(adapter, chat_id: str, media_files: list, metadata:
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from gateway.platforms.base import should_send_media_as_audio
|
||||
|
||||
for media_path, _is_voice in media_files:
|
||||
try:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
if ext in _AUDIO_EXTS:
|
||||
route_platform = platform if platform is not None else getattr(adapter, "platform", None)
|
||||
if should_send_media_as_audio(route_platform, ext, is_voice=_is_voice):
|
||||
coro = adapter.send_voice(chat_id=chat_id, audio_path=media_path, metadata=metadata)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
coro = adapter.send_video(chat_id=chat_id, video_path=media_path, metadata=metadata)
|
||||
@@ -341,27 +352,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
from tools.send_message_tool import _send_to_platform
|
||||
from gateway.config import load_gateway_config, Platform
|
||||
|
||||
platform_map = {
|
||||
"telegram": Platform.TELEGRAM,
|
||||
"discord": Platform.DISCORD,
|
||||
"slack": Platform.SLACK,
|
||||
"whatsapp": Platform.WHATSAPP,
|
||||
"signal": Platform.SIGNAL,
|
||||
"matrix": Platform.MATRIX,
|
||||
"mattermost": Platform.MATTERMOST,
|
||||
"homeassistant": Platform.HOMEASSISTANT,
|
||||
"dingtalk": Platform.DINGTALK,
|
||||
"feishu": Platform.FEISHU,
|
||||
"wecom": Platform.WECOM,
|
||||
"wecom_callback": Platform.WECOM_CALLBACK,
|
||||
"weixin": Platform.WEIXIN,
|
||||
"email": Platform.EMAIL,
|
||||
"sms": Platform.SMS,
|
||||
"bluebubbles": Platform.BLUEBUBBLES,
|
||||
"qqbot": Platform.QQBOT,
|
||||
"yuanbao": Platform.YUANBAO,
|
||||
}
|
||||
|
||||
# Optionally wrap the content with a header/footer so the user knows this
|
||||
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
|
||||
# in config.yaml for clean output.
|
||||
@@ -418,13 +408,23 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
job["id"], platform_name, chat_id, thread_id,
|
||||
)
|
||||
|
||||
platform = platform_map.get(platform_name.lower())
|
||||
if not platform:
|
||||
# Built-in names resolve to their enum member; plugin platform names
|
||||
# create dynamic members via Platform._missing_().
|
||||
try:
|
||||
platform = Platform(platform_name.lower())
|
||||
except (ValueError, KeyError):
|
||||
msg = f"unknown platform '{platform_name}'"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
pconfig = config.platforms.get(platform)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
msg = f"platform '{platform_name}' not configured/enabled"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
# Prefer the live adapter when the gateway is running — this supports E2EE
|
||||
# rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
|
||||
runtime_adapter = (adapters or {}).get(platform)
|
||||
@@ -455,7 +455,15 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
|
||||
# Send extracted media files as native attachments via the live adapter
|
||||
if adapter_ok and media_files:
|
||||
_send_media_via_adapter(runtime_adapter, chat_id, media_files, send_metadata, loop, job)
|
||||
_send_media_via_adapter(
|
||||
runtime_adapter,
|
||||
chat_id,
|
||||
media_files,
|
||||
send_metadata,
|
||||
loop,
|
||||
job,
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
if adapter_ok:
|
||||
logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
|
||||
@@ -467,13 +475,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
)
|
||||
|
||||
if not delivered:
|
||||
pconfig = config.platforms.get(platform)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
msg = f"platform '{platform_name}' not configured/enabled"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
# Standalone path: run the async send in a fresh event loop (safe from any thread)
|
||||
coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)
|
||||
try:
|
||||
@@ -860,6 +861,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
chat_id=str(origin["chat_id"]) if origin else "",
|
||||
chat_name=origin.get("chat_name", "") if origin else "",
|
||||
)
|
||||
_cron_delivery_vars = (
|
||||
"HERMES_CRON_AUTO_DELIVER_PLATFORM",
|
||||
"HERMES_CRON_AUTO_DELIVER_CHAT_ID",
|
||||
"HERMES_CRON_AUTO_DELIVER_THREAD_ID",
|
||||
)
|
||||
for _var_name in _cron_delivery_vars:
|
||||
_VAR_MAP[_var_name].set("")
|
||||
|
||||
# Per-job working directory. When set (and validated at create/update
|
||||
# time), we point TERMINAL_CWD at it so:
|
||||
@@ -898,8 +906,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
if delivery_target:
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_PLATFORM"].set(delivery_target["platform"])
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_CHAT_ID"].set(str(delivery_target["chat_id"]))
|
||||
if delivery_target.get("thread_id") is not None:
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(str(delivery_target["thread_id"]))
|
||||
_VAR_MAP["HERMES_CRON_AUTO_DELIVER_THREAD_ID"].set(
|
||||
""
|
||||
if delivery_target.get("thread_id") is None
|
||||
else str(delivery_target["thread_id"])
|
||||
)
|
||||
|
||||
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
|
||||
|
||||
@@ -1139,6 +1150,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
f"agent.run_conversation returned {type(result).__name__} instead of dict: {result!r}"
|
||||
)
|
||||
|
||||
# If the agent itself reported failure (e.g. all retries exhausted on
|
||||
# API errors, model abort, mid-run interrupt), do not silently mark the
|
||||
# job as successful. run_agent populates `failed=True`/`completed=False`
|
||||
# on these paths and may put the error into `final_response`, which
|
||||
# would otherwise be delivered as if it were the agent's reply and the
|
||||
# job's `last_status` set to "ok". Raise so the except handler below
|
||||
# builds the proper failure tuple. (issue #17855)
|
||||
if result.get("failed") is True or result.get("completed") is False:
|
||||
_err_text = (
|
||||
result.get("error")
|
||||
or (result.get("final_response") or "").strip()
|
||||
or "agent reported failure"
|
||||
)
|
||||
raise RuntimeError(_err_text)
|
||||
|
||||
final_response = result.get("final_response", "") or ""
|
||||
# Strip leaked placeholder text that upstream may inject on empty completions.
|
||||
if final_response.strip() == "(No response generated)":
|
||||
@@ -1198,6 +1224,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
os.environ["TERMINAL_CWD"] = _prior_terminal_cwd
|
||||
# Clean up ContextVar session/delivery state for this job.
|
||||
clear_session_vars(_ctx_tokens)
|
||||
for _var_name in _cron_delivery_vars:
|
||||
_VAR_MAP[_var_name].set("")
|
||||
if _session_db:
|
||||
try:
|
||||
_session_db.end_session(_cron_session_id, "cron_complete")
|
||||
|
||||
@@ -34,6 +34,13 @@ services:
|
||||
# uncomment BOTH lines (API_SERVER_KEY is mandatory for auth):
|
||||
# - API_SERVER_HOST=0.0.0.0
|
||||
# - API_SERVER_KEY=${API_SERVER_KEY}
|
||||
# Microsoft Teams — uncomment and fill in to enable Teams gateway.
|
||||
# Register your bot at https://dev.botframework.com/ to get these values.
|
||||
# - TEAMS_CLIENT_ID=${TEAMS_CLIENT_ID}
|
||||
# - TEAMS_CLIENT_SECRET=${TEAMS_CLIENT_SECRET}
|
||||
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
|
||||
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
|
||||
# - TEAMS_PORT=3978
|
||||
command: ["gateway", "run"]
|
||||
|
||||
dashboard:
|
||||
|
||||
@@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
continue
|
||||
platforms[plat_name] = _build_from_sessions(plat_name)
|
||||
|
||||
# Include plugin-registered platforms (dynamic enum members aren't in
|
||||
# Platform.__members__, so the loop above misses them).
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
|
||||
platforms[entry.name] = _build_from_sessions(entry.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
directory = {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"platforms": platforms,
|
||||
|
||||
+193
-60
@@ -13,7 +13,7 @@ import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
@@ -45,8 +45,19 @@ def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> st
|
||||
return default
|
||||
|
||||
|
||||
# Module-level cache for bundled platform plugin names (lives outside the
|
||||
# enum so it doesn't become an accidental enum member).
|
||||
_Platform__bundled_plugin_names: Optional[set] = None
|
||||
|
||||
|
||||
class Platform(Enum):
|
||||
"""Supported messaging platforms."""
|
||||
"""Supported messaging platforms.
|
||||
|
||||
Built-in platforms have explicit members. Plugin platforms use dynamic
|
||||
members created on-demand by ``_missing_()`` so that
|
||||
``Platform("irc")`` works without modifying this enum. Dynamic members
|
||||
are cached in ``_value2member_map_`` for identity-stable comparisons.
|
||||
"""
|
||||
LOCAL = "local"
|
||||
TELEGRAM = "telegram"
|
||||
DISCORD = "discord"
|
||||
@@ -68,6 +79,76 @@ class Platform(Enum):
|
||||
BLUEBUBBLES = "bluebubbles"
|
||||
QQBOT = "qqbot"
|
||||
YUANBAO = "yuanbao"
|
||||
@classmethod
|
||||
def _missing_(cls, value):
|
||||
"""Accept unknown platform names only for known plugin adapters.
|
||||
|
||||
Creates a pseudo-member cached in ``_value2member_map_`` so that
|
||||
``Platform("irc") is Platform("irc")`` holds True (identity-stable).
|
||||
Arbitrary strings are rejected to prevent enum pollution.
|
||||
"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
# Normalise to lowercase to avoid case mismatches in config
|
||||
value = value.strip().lower()
|
||||
# Check cache first (another call may have created it already)
|
||||
if value in cls._value2member_map_:
|
||||
return cls._value2member_map_[value]
|
||||
|
||||
# Only create pseudo-members for bundled plugin platforms (discovered
|
||||
# via filesystem scan) or runtime-registered plugin platforms.
|
||||
global _Platform__bundled_plugin_names
|
||||
if _Platform__bundled_plugin_names is None:
|
||||
_Platform__bundled_plugin_names = cls._scan_bundled_plugin_platforms()
|
||||
if value in _Platform__bundled_plugin_names:
|
||||
pseudo = object.__new__(cls)
|
||||
pseudo._value_ = value
|
||||
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
|
||||
cls._value2member_map_[value] = pseudo
|
||||
cls._member_map_[pseudo._name_] = pseudo
|
||||
return pseudo
|
||||
|
||||
# Runtime-registered plugins (e.g. user-installed, discovered after
|
||||
# the enum was defined).
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
if platform_registry.is_registered(value):
|
||||
pseudo = object.__new__(cls)
|
||||
pseudo._value_ = value
|
||||
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
|
||||
cls._value2member_map_[value] = pseudo
|
||||
cls._member_map_[pseudo._name_] = pseudo
|
||||
return pseudo
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _scan_bundled_plugin_platforms(cls) -> set:
|
||||
"""Return names of bundled platform plugins under ``plugins/platforms/``."""
|
||||
names: set = set()
|
||||
try:
|
||||
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
|
||||
if platforms_dir.is_dir():
|
||||
for child in platforms_dir.iterdir():
|
||||
if (
|
||||
child.is_dir()
|
||||
and (child / "__init__.py").exists()
|
||||
and (
|
||||
(child / "plugin.yaml").exists()
|
||||
or (child / "plugin.yml").exists()
|
||||
)
|
||||
):
|
||||
names.add(child.name.lower())
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
# Snapshot of built-in platform values before any dynamic _missing_ lookups.
|
||||
# Used to distinguish real platforms from arbitrary strings.
|
||||
_BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -231,6 +312,44 @@ class StreamingConfig:
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Built-in platform connection checkers
|
||||
# -----------------------------------------------------------------------------
|
||||
# Each callable receives a ``PlatformConfig`` and returns ``True`` when the
|
||||
# platform is sufficiently configured to be considered "connected". Platforms
|
||||
# that rely on the generic ``token or api_key`` check (Telegram, Discord,
|
||||
# Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here.
|
||||
_PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = {
|
||||
Platform.WEIXIN: lambda cfg: bool(
|
||||
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
|
||||
),
|
||||
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
|
||||
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
|
||||
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
|
||||
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
|
||||
Platform.API_SERVER: lambda cfg: True,
|
||||
Platform.WEBHOOK: lambda cfg: True,
|
||||
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
|
||||
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
|
||||
Platform.WECOM_CALLBACK: lambda cfg: bool(
|
||||
cfg.extra.get("corp_id") or cfg.extra.get("apps")
|
||||
),
|
||||
Platform.BLUEBUBBLES: lambda cfg: bool(
|
||||
cfg.extra.get("server_url") and cfg.extra.get("password")
|
||||
),
|
||||
Platform.QQBOT: lambda cfg: bool(
|
||||
cfg.extra.get("app_id") and cfg.extra.get("client_secret")
|
||||
),
|
||||
Platform.YUANBAO: lambda cfg: bool(
|
||||
cfg.extra.get("app_id") and cfg.extra.get("app_secret")
|
||||
),
|
||||
Platform.DINGTALK: lambda cfg: bool(
|
||||
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
|
||||
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""
|
||||
@@ -284,61 +403,43 @@ class GatewayConfig:
|
||||
for platform, config in self.platforms.items():
|
||||
if not config.enabled:
|
||||
continue
|
||||
# Weixin requires both a token and an account_id
|
||||
if platform == Platform.WEIXIN:
|
||||
if config.extra.get("account_id") and (config.token or config.extra.get("token")):
|
||||
connected.append(platform)
|
||||
continue
|
||||
# Platforms that use token/api_key auth
|
||||
if config.token or config.api_key:
|
||||
if self._is_platform_connected(platform, config):
|
||||
connected.append(platform)
|
||||
# WhatsApp uses enabled flag only (bridge handles auth)
|
||||
elif platform == Platform.WHATSAPP:
|
||||
connected.append(platform)
|
||||
# Signal uses extra dict for config (http_url + account)
|
||||
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
|
||||
connected.append(platform)
|
||||
# Email uses extra dict for config (address + imap_host + smtp_host)
|
||||
elif platform == Platform.EMAIL and config.extra.get("address"):
|
||||
connected.append(platform)
|
||||
# SMS uses api_key (Twilio auth token) — SID checked via env
|
||||
elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
|
||||
connected.append(platform)
|
||||
# API Server uses enabled flag only (no token needed)
|
||||
elif platform == Platform.API_SERVER:
|
||||
connected.append(platform)
|
||||
# Webhook uses enabled flag only (secrets are per-route)
|
||||
elif platform == Platform.WEBHOOK:
|
||||
connected.append(platform)
|
||||
# Feishu uses extra dict for app credentials
|
||||
elif platform == Platform.FEISHU and config.extra.get("app_id"):
|
||||
connected.append(platform)
|
||||
# WeCom bot mode uses extra dict for bot credentials
|
||||
elif platform == Platform.WECOM and config.extra.get("bot_id"):
|
||||
connected.append(platform)
|
||||
# WeCom callback mode uses corp_id or apps list
|
||||
elif platform == Platform.WECOM_CALLBACK and (
|
||||
config.extra.get("corp_id") or config.extra.get("apps")
|
||||
):
|
||||
connected.append(platform)
|
||||
# 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)
|
||||
# Yuanbao uses extra dict for app credentials
|
||||
elif platform == Platform.YUANBAO and config.extra.get("app_id") and config.extra.get("app_secret"):
|
||||
connected.append(platform)
|
||||
# DingTalk uses client_id/client_secret from config.extra or env vars
|
||||
elif platform == Platform.DINGTALK and (
|
||||
config.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")
|
||||
) and (
|
||||
config.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET")
|
||||
):
|
||||
connected.append(platform)
|
||||
|
||||
return connected
|
||||
|
||||
def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool:
|
||||
"""Check whether a single platform is sufficiently configured."""
|
||||
# Weixin requires both a token and an account_id (checked first so
|
||||
# the generic token branch doesn't let it through without account_id).
|
||||
if platform == Platform.WEIXIN:
|
||||
return bool(
|
||||
config.extra.get("account_id")
|
||||
and (config.token or config.extra.get("token"))
|
||||
)
|
||||
|
||||
# Generic token/api_key auth covers Telegram, Discord, Slack, etc.
|
||||
if config.token or config.api_key:
|
||||
return True
|
||||
|
||||
# Platform-specific check
|
||||
checker = _PLATFORM_CONNECTED_CHECKERS.get(platform)
|
||||
if checker is not None:
|
||||
return checker(config)
|
||||
|
||||
# Plugin-registered platforms
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(platform.value)
|
||||
if entry:
|
||||
if entry.is_connected is not None:
|
||||
return entry.is_connected(config)
|
||||
if entry.validate_config is not None:
|
||||
return entry.validate_config(config)
|
||||
return True
|
||||
except Exception:
|
||||
pass # Registry not yet initialised during early import
|
||||
|
||||
return False
|
||||
|
||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||
"""Get the home channel for a platform."""
|
||||
@@ -714,11 +815,21 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
|
||||
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
|
||||
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
|
||||
if "group_allowed_chats" in telegram_cfg and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
|
||||
gac = telegram_cfg["group_allowed_chats"]
|
||||
if isinstance(gac, list):
|
||||
gac = ",".join(str(v) for v in gac)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(gac)
|
||||
allowed_users = telegram_cfg.get("allow_from")
|
||||
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
|
||||
group_allowed_users = telegram_cfg.get("group_allow_from")
|
||||
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
|
||||
if isinstance(group_allowed_users, list):
|
||||
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
|
||||
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
|
||||
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
|
||||
if isinstance(group_allowed_chats, list):
|
||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
||||
if "disable_link_previews" in telegram_cfg:
|
||||
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
@@ -1371,3 +1482,25 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.default_reset_policy.at_hour = int(reset_hour)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Registry-driven enable for plugin platforms. Built-ins have explicit
|
||||
# blocks above; plugins expose check_fn() which is the single source of
|
||||
# truth for "are my env vars set?". When it returns True, ensure the
|
||||
# platform is enabled so start() will create its adapter.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins() # idempotent
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
try:
|
||||
if not entry.check_fn():
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("check_fn for %s raised: %s", entry.name, e)
|
||||
continue
|
||||
platform = Platform(entry.name)
|
||||
if platform not in config.platforms:
|
||||
config.platforms[platform] = PlatformConfig()
|
||||
config.platforms[platform].enabled = True
|
||||
except Exception as e:
|
||||
logger.debug("Plugin platform enable pass failed: %s", e)
|
||||
|
||||
+16
-3
@@ -21,6 +21,7 @@ Errors in hooks are caught and logged but never block the main pipeline.
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
@@ -97,16 +98,28 @@ class HookRegistry:
|
||||
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
|
||||
continue
|
||||
|
||||
# Dynamically load the handler module
|
||||
# Dynamically load the handler module.
|
||||
# Register in sys.modules BEFORE exec_module so Pydantic /
|
||||
# dataclasses / typing introspection can resolve forward
|
||||
# references (triggered by `from __future__ import annotations`
|
||||
# in the handler). Without this, a handler that declares a
|
||||
# Pydantic BaseModel for webhook/event payloads fails at first
|
||||
# dispatch with "TypeAdapter ... is not fully defined".
|
||||
module_name = f"hermes_hook_{hook_name}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_hook_{hook_name}", handler_path
|
||||
module_name, handler_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
|
||||
continue
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
|
||||
handle_fn = getattr(module, "handle", None)
|
||||
if handle_fn is None:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Platform Adapter Registry
|
||||
|
||||
Allows platform adapters (built-in and plugin) to self-register so the gateway
|
||||
can discover and instantiate them without hardcoded if/elif chains.
|
||||
|
||||
Built-in adapters continue to use the existing if/elif in _create_adapter()
|
||||
for now. Plugin adapters register here via PluginContext.register_platform()
|
||||
and are looked up first -- if nothing is found the gateway falls through to
|
||||
the legacy code path.
|
||||
|
||||
Usage (plugin side):
|
||||
|
||||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||||
|
||||
platform_registry.register(PlatformEntry(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=lambda cfg: bool(cfg.extra.get("server")),
|
||||
required_env=["IRC_SERVER"],
|
||||
install_hint="pip install irc",
|
||||
))
|
||||
|
||||
Usage (gateway side):
|
||||
|
||||
adapter = platform_registry.create_adapter("irc", platform_config)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformEntry:
|
||||
"""Metadata and factory for a single platform adapter."""
|
||||
|
||||
# Identifier used in config.yaml (e.g. "irc", "viber").
|
||||
name: str
|
||||
|
||||
# Human-readable label (e.g. "IRC", "Viber").
|
||||
label: str
|
||||
|
||||
# Factory callable: receives a PlatformConfig, returns an adapter instance.
|
||||
# Using a factory instead of a bare class lets plugins do custom init
|
||||
# (e.g. passing extra kwargs, wrapping in try/except).
|
||||
adapter_factory: Callable[[Any], Any]
|
||||
|
||||
# Returns True when the platform's dependencies are available.
|
||||
check_fn: Callable[[], bool]
|
||||
|
||||
# Optional: given a PlatformConfig, is it properly configured?
|
||||
# If None, the registry skips config validation and lets the adapter
|
||||
# fail at connect() time with a descriptive error.
|
||||
validate_config: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
# Optional: given a PlatformConfig, is the platform connected/enabled?
|
||||
# Used by ``GatewayConfig.get_connected_platforms()`` and setup UI status.
|
||||
# If None, falls back to ``validate_config`` or ``check_fn``.
|
||||
is_connected: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
# Env vars this platform needs (for ``hermes setup`` display).
|
||||
required_env: list = field(default_factory=list)
|
||||
|
||||
# Hint shown when check_fn returns False.
|
||||
install_hint: str = ""
|
||||
|
||||
# Optional setup function for interactive configuration.
|
||||
# Signature: () -> None (prompts user, saves env vars).
|
||||
# If None, falls back to _setup_standard_platform (needs token_var + vars)
|
||||
# or a generic "set these env vars" display.
|
||||
setup_fn: Optional[Callable[[], None]] = None
|
||||
|
||||
# "builtin" or "plugin"
|
||||
source: str = "plugin"
|
||||
|
||||
# Name of the plugin manifest that registered this entry (empty for
|
||||
# built-ins). Used by ``hermes gateway setup`` to auto-enable the
|
||||
# owning plugin when the user configures its platform.
|
||||
plugin_name: str = ""
|
||||
|
||||
# ── Auth env var names (for _is_user_authorized integration) ──
|
||||
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
|
||||
allowed_users_env: str = ""
|
||||
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
|
||||
allow_all_env: str = ""
|
||||
|
||||
# ── Message limits ──
|
||||
# Max message length for smart-chunking. 0 = no limit.
|
||||
max_message_length: int = 0
|
||||
|
||||
# ── Privacy ──
|
||||
# If True, session descriptions redact PII (phone numbers, etc.)
|
||||
pii_safe: bool = False
|
||||
|
||||
# ── Display ──
|
||||
# Emoji for CLI/gateway display (e.g. "💬")
|
||||
emoji: str = "🔌"
|
||||
|
||||
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
|
||||
# (allows /update command from this platform).
|
||||
allow_update_command: bool = True
|
||||
|
||||
# ── LLM guidance ──
|
||||
# Platform hint injected into the system prompt (e.g. "You are on IRC.
|
||||
# Do not use markdown."). Empty string = no hint.
|
||||
platform_hint: str = ""
|
||||
|
||||
|
||||
class PlatformRegistry:
|
||||
"""Central registry of platform adapters.
|
||||
|
||||
Thread-safe for reads (dict lookups are atomic under GIL).
|
||||
Writes happen at startup during sequential discovery.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: dict[str, PlatformEntry] = {}
|
||||
|
||||
def register(self, entry: PlatformEntry) -> None:
|
||||
"""Register a platform adapter entry.
|
||||
|
||||
If an entry with the same name exists, it is replaced (last writer
|
||||
wins -- this lets plugins override built-in adapters if desired).
|
||||
"""
|
||||
if entry.name in self._entries:
|
||||
prev = self._entries[entry.name]
|
||||
logger.info(
|
||||
"Platform '%s' re-registered (was %s, now %s)",
|
||||
entry.name,
|
||||
prev.source,
|
||||
entry.source,
|
||||
)
|
||||
self._entries[entry.name] = entry
|
||||
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Remove a platform entry. Returns True if it existed."""
|
||||
return self._entries.pop(name, None) is not None
|
||||
|
||||
def get(self, name: str) -> Optional[PlatformEntry]:
|
||||
"""Look up a platform entry by name."""
|
||||
return self._entries.get(name)
|
||||
|
||||
def all_entries(self) -> list[PlatformEntry]:
|
||||
"""Return all registered platform entries."""
|
||||
return list(self._entries.values())
|
||||
|
||||
def plugin_entries(self) -> list[PlatformEntry]:
|
||||
"""Return only plugin-registered platform entries."""
|
||||
return [e for e in self._entries.values() if e.source == "plugin"]
|
||||
|
||||
def is_registered(self, name: str) -> bool:
|
||||
return name in self._entries
|
||||
|
||||
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
|
||||
"""Create an adapter instance for the given platform name.
|
||||
|
||||
Returns None if:
|
||||
- No entry registered for *name*
|
||||
- check_fn() returns False (missing deps)
|
||||
- validate_config() returns False (misconfigured)
|
||||
- The factory raises an exception
|
||||
"""
|
||||
entry = self._entries.get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
if not entry.check_fn():
|
||||
hint = f" ({entry.install_hint})" if entry.install_hint else ""
|
||||
logger.warning(
|
||||
"Platform '%s' requirements not met%s",
|
||||
entry.label,
|
||||
hint,
|
||||
)
|
||||
return None
|
||||
|
||||
if entry.validate_config is not None:
|
||||
try:
|
||||
if not entry.validate_config(config):
|
||||
logger.warning(
|
||||
"Platform '%s' config validation failed",
|
||||
entry.label,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Platform '%s' config validation error: %s",
|
||||
entry.label,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
adapter = entry.adapter_factory(config)
|
||||
return adapter
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create adapter for platform '%s': %s",
|
||||
entry.label,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
platform_registry = PlatformRegistry()
|
||||
@@ -1,9 +1,30 @@
|
||||
# Adding a New Messaging Platform
|
||||
|
||||
Checklist for integrating a new messaging platform into the Hermes gateway.
|
||||
Use this as a reference when building a new adapter — every item here is a
|
||||
real integration point that exists in the codebase. Missing any of them will
|
||||
cause broken functionality, missing features, or inconsistent behavior.
|
||||
There are two ways to add a platform to the Hermes gateway:
|
||||
|
||||
## Plugin Path (Recommended for Community/Third-Party)
|
||||
|
||||
Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and
|
||||
`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers
|
||||
via `ctx.register_platform()` in the `register(ctx)` entry point. This
|
||||
requires **zero changes to core Hermes code**.
|
||||
|
||||
The plugin system automatically handles: adapter creation, config parsing,
|
||||
user authorization, cron delivery, send_message routing, system prompt hints,
|
||||
status display, gateway setup, and more.
|
||||
|
||||
See `plugins/platforms/irc/` for a complete reference implementation, and
|
||||
`website/docs/developer-guide/adding-platform-adapters.md` for the full
|
||||
plugin guide with code examples.
|
||||
|
||||
---
|
||||
|
||||
## Built-in Path (Core Contributors Only)
|
||||
|
||||
Checklist for integrating a platform directly into the Hermes core.
|
||||
Use this as a reference when building a built-in adapter — every item here
|
||||
is a real integration point. Missing any of them will cause broken
|
||||
functionality, missing features, or inconsistent behavior.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+301
-84
@@ -23,6 +23,45 @@ from utils import normalize_proxy_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Audio file extensions Hermes recognizes for native audio delivery.
|
||||
# Kept in sync with tools/send_message_tool.py and cron/scheduler.py via
|
||||
# should_send_media_as_audio() below.
|
||||
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
|
||||
# Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
|
||||
# formats either need to go through sendVoice (Opus/OGG) or must be
|
||||
# delivered as a regular document.
|
||||
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
|
||||
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
|
||||
|
||||
|
||||
def _platform_name(platform) -> str:
|
||||
"""Normalize a Platform enum / raw string into a lowercase name."""
|
||||
value = getattr(platform, "value", platform)
|
||||
return str(value or "").lower()
|
||||
|
||||
|
||||
def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:
|
||||
"""Return True when a media file should use the platform's audio sender.
|
||||
|
||||
Other platforms: every recognized audio extension routes through the
|
||||
audio sender.
|
||||
|
||||
Telegram: the Bot API only accepts MP3/M4A for sendAudio and
|
||||
Opus/OGG for sendVoice. Opus/OGG is only routed as audio when the
|
||||
caller flagged ``is_voice=True`` (so we don't turn a regular audio
|
||||
attachment into a voice bubble just because the file happens to be
|
||||
Opus). Everything else falls through to document delivery by
|
||||
returning ``False``.
|
||||
"""
|
||||
normalized_ext = (ext or "").lower()
|
||||
if normalized_ext not in _AUDIO_EXTS:
|
||||
return False
|
||||
if _platform_name(platform) == "telegram":
|
||||
if normalized_ext in _TELEGRAM_VOICE_EXTS:
|
||||
return is_voice
|
||||
return normalized_ext in _TELEGRAM_AUDIO_ATTACHMENT_EXTS
|
||||
return True
|
||||
|
||||
|
||||
def utf16_len(s: str) -> int:
|
||||
"""Count UTF-16 code units in *s*.
|
||||
@@ -1415,6 +1454,41 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
async def send_slash_confirm(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
message: str,
|
||||
session_key: str,
|
||||
confirm_id: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a three-option slash-command confirmation prompt.
|
||||
|
||||
Used by the gateway's generic slash-confirm primitive (see
|
||||
``GatewayRunner._request_slash_confirm``) for commands that have a
|
||||
non-destructive but expensive side effect the user should explicitly
|
||||
acknowledge — the current caller is ``/reload-mcp``, which
|
||||
invalidates the provider prompt cache.
|
||||
|
||||
Platforms with inline-button support (Telegram, Discord, Slack,
|
||||
Matrix, Feishu) should override this to render three buttons:
|
||||
Approve Once / Always Approve / Cancel. Button callbacks MUST be
|
||||
routed back through the gateway by calling
|
||||
``GatewayRunner._resolve_slash_confirm(confirm_id, choice)`` where
|
||||
``choice`` is ``"once"`` / ``"always"`` / ``"cancel"``.
|
||||
|
||||
Platforms without button UIs leave this as the default and fall
|
||||
through to the gateway's text fallback (which sends ``message`` as
|
||||
plain text and intercepts the next ``/approve`` / ``/always`` /
|
||||
``/cancel`` reply).
|
||||
|
||||
``confirm_id`` is a short string generated by the gateway; the
|
||||
adapter stores it alongside any platform-specific state needed to
|
||||
route the callback (e.g. Telegram's ``_approval_state`` dict).
|
||||
"""
|
||||
return SendResult(success=False, error="Not supported")
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""
|
||||
Send a typing indicator.
|
||||
@@ -1431,7 +1505,64 @@ class BasePlatformAdapter(ABC):
|
||||
Default is a no-op for platforms with one-shot typing indicators.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images.
|
||||
|
||||
Accepts ``http(s)://``, ``file://`` URIs in the first tuple
|
||||
element.
|
||||
|
||||
Default implementation sends each item individually,
|
||||
routing animated GIFs through ``send_animation`` and local
|
||||
files through ``send_image_file``.
|
||||
|
||||
Override in subclasses to bundle into a single native API call
|
||||
(e.g. Signal's multi-attachment RPC)
|
||||
"""
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
for image_url, alt_text in images:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
logger.info(
|
||||
"[%s] Sending image: %s (alt=%s)",
|
||||
self.name,
|
||||
safe_url_for_log(image_url),
|
||||
alt_text[:30] if alt_text else "",
|
||||
)
|
||||
if image_url.startswith("file://"):
|
||||
img_result = await self.send_image_file(
|
||||
chat_id=chat_id,
|
||||
image_path=_unquote(image_url[7:]),
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
elif self._is_animation_url(image_url):
|
||||
img_result = await self.send_animation(
|
||||
chat_id=chat_id,
|
||||
animation_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
img_result = await self.send_image(
|
||||
chat_id=chat_id,
|
||||
image_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=metadata,
|
||||
)
|
||||
if not img_result.success:
|
||||
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
|
||||
except Exception as img_err:
|
||||
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1640,7 +1771,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
)
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
@@ -1780,11 +1911,19 @@ class BasePlatformAdapter(ABC):
|
||||
if stop_event is None:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + interval
|
||||
while not stop_event.is_set():
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
# Poll instead of wait_for(stop_event.wait()). Cancelling
|
||||
# wait_for while it owns the inner Event.wait task can leave
|
||||
# shutdown paths stuck awaiting the typing task on Python
|
||||
# 3.11/pytest-asyncio; sleep cancellation is immediate.
|
||||
await asyncio.sleep(min(0.25, remaining))
|
||||
if stop_event.is_set():
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
pass # Normal cancellation when handler completes
|
||||
finally:
|
||||
@@ -2117,6 +2256,12 @@ class BasePlatformAdapter(ABC):
|
||||
``release_guard=False`` keeps the adapter-level session guard in place
|
||||
so reset-like commands can finish atomically before follow-up messages
|
||||
are allowed to start a fresh background task.
|
||||
|
||||
Bounded by a 5s timeout so a wedged finally block in the cancelled
|
||||
task (typing-task cleanup, on_processing_complete hook, etc.) can't
|
||||
stall the calling dispatch coroutine — particularly under pytest-
|
||||
asyncio where the event loop's cancellation-propagation semantics
|
||||
differ subtly from a bare ``asyncio.run`` harness.
|
||||
"""
|
||||
task = self._session_tasks.pop(session_key, None)
|
||||
if task is not None and not task.done():
|
||||
@@ -2128,9 +2273,15 @@ class BasePlatformAdapter(ABC):
|
||||
self._expected_cancelled_tasks.add(task)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[%s] Cancelled task for %s did not exit within 5s; "
|
||||
"unblocking dispatch and letting the task unwind in the background",
|
||||
self.name, session_key,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[%s] Session cancellation raised while unwinding %s",
|
||||
@@ -2382,6 +2533,16 @@ class BasePlatformAdapter(ABC):
|
||||
**_keep_typing_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
async def _stop_typing_task() -> None:
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
# Cancellation cleanup must not block adapter shutdown. The
|
||||
# typing task is already cancelled; if the parent task is also
|
||||
# cancelling, let this message-processing task unwind now.
|
||||
pass
|
||||
|
||||
try:
|
||||
await self._run_processing_hook("on_processing_start", event)
|
||||
@@ -2483,47 +2644,57 @@ class BasePlatformAdapter(ABC):
|
||||
# Send extracted images as native attachments
|
||||
if images:
|
||||
logger.info("[%s] Extracted %d image(s) to send as attachments", self.name, len(images))
|
||||
for image_url, alt_text in images:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
logger.info(
|
||||
"[%s] Sending image: %s (alt=%s)",
|
||||
self.name,
|
||||
safe_url_for_log(image_url),
|
||||
alt_text[:30] if alt_text else "",
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=images,
|
||||
metadata=_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
# Route animated GIFs through send_animation for proper playback
|
||||
if self._is_animation_url(image_url):
|
||||
img_result = await self.send_animation(
|
||||
chat_id=event.source.chat_id,
|
||||
animation_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
img_result = await self.send_image(
|
||||
chat_id=event.source.chat_id,
|
||||
image_url=image_url,
|
||||
caption=alt_text if alt_text else None,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
if not img_result.success:
|
||||
logger.error("[%s] Failed to send image: %s", self.name, img_result.error)
|
||||
except Exception as img_err:
|
||||
logger.error("[%s] Error sending image: %s", self.name, img_err, exc_info=True)
|
||||
except Exception as batch_err:
|
||||
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
|
||||
|
||||
|
||||
# Send extracted media files — route by file type
|
||||
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
# Partition images out of media_files + local_files so they
|
||||
# can be sent as a single batch (Signal RPC)
|
||||
from urllib.parse import quote as _quote
|
||||
_image_paths: list = []
|
||||
_non_image_media: list = []
|
||||
for media_path, is_voice in media_files:
|
||||
_ext = Path(media_path).suffix.lower()
|
||||
if _ext in _IMAGE_EXTS and not is_voice:
|
||||
_image_paths.append(media_path)
|
||||
else:
|
||||
_non_image_media.append((media_path, is_voice))
|
||||
_non_image_local: list = []
|
||||
for file_path in local_files:
|
||||
if Path(file_path).suffix.lower() in _IMAGE_EXTS:
|
||||
_image_paths.append(file_path)
|
||||
else:
|
||||
_non_image_local.append(file_path)
|
||||
|
||||
if _image_paths:
|
||||
try:
|
||||
_batch = [(f"file://{_quote(p)}", "") for p in _image_paths]
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=_batch,
|
||||
metadata=_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
except Exception as batch_err:
|
||||
logger.warning("[%s] Error batching images: %s", self.name, batch_err, exc_info=True)
|
||||
|
||||
for media_path, is_voice in _non_image_media:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
if ext in _AUDIO_EXTS:
|
||||
if should_send_media_as_audio(self.platform, ext, is_voice=is_voice):
|
||||
media_result = await self.send_voice(
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=media_path,
|
||||
@@ -2535,12 +2706,6 @@ class BasePlatformAdapter(ABC):
|
||||
video_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif ext in _IMAGE_EXTS:
|
||||
media_result = await self.send_image_file(
|
||||
chat_id=event.source.chat_id,
|
||||
image_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
media_result = await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
@@ -2553,19 +2718,13 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as media_err:
|
||||
logger.warning("[%s] Error sending media: %s", self.name, media_err)
|
||||
|
||||
# Send auto-detected local files as native attachments
|
||||
for file_path in local_files:
|
||||
# Send auto-detected local non-image files as native attachments
|
||||
for file_path in _non_image_local:
|
||||
if human_delay > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
try:
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
await self.send_image_file(
|
||||
chat_id=event.source.chat_id,
|
||||
image_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
if ext in _VIDEO_EXTS:
|
||||
await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=file_path,
|
||||
@@ -2604,14 +2763,28 @@ class BasePlatformAdapter(ABC):
|
||||
_active = self._active_sessions.get(session_key)
|
||||
if _active is not None:
|
||||
_active.clear()
|
||||
typing_task.cancel()
|
||||
await _stop_typing_task()
|
||||
# Spawn a fresh task for the pending message instead of
|
||||
# recursing. Issue #17758: `await
|
||||
# self._process_message_background(...)` here grew the
|
||||
# call stack one frame per chained follow-up, and under
|
||||
# sustained pending-queue activity the C stack would
|
||||
# exhaust at ~2000 frames and SIGSEGV the process.
|
||||
# Mirror the late-arrival drain pattern below: hand off
|
||||
# to a new task and return so this frame can unwind.
|
||||
drain_task = asyncio.create_task(
|
||||
self._process_message_background(pending_event, session_key)
|
||||
)
|
||||
# Hand ownership of the session to the drain task so
|
||||
# stale-lock detection keeps working while it runs.
|
||||
self._session_tasks[session_key] = drain_task
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
self._background_tasks.add(drain_task)
|
||||
drain_task.add_done_callback(self._background_tasks.discard)
|
||||
except TypeError:
|
||||
# Tests stub create_task() with non-hashable sentinels; tolerate.
|
||||
pass
|
||||
# Process pending message in new background task
|
||||
await self._process_message_background(pending_event, session_key)
|
||||
return # Already cleaned up
|
||||
return # Drain task owns the session now.
|
||||
|
||||
except asyncio.CancelledError:
|
||||
current_task = asyncio.current_task()
|
||||
@@ -2656,11 +2829,7 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception:
|
||||
pass
|
||||
# Stop typing indicator
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await _stop_typing_task()
|
||||
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
|
||||
# that may have been recreated by _keep_typing after the last stop_typing()
|
||||
try:
|
||||
@@ -2677,25 +2846,41 @@ class BasePlatformAdapter(ABC):
|
||||
# dropped (user never gets a reply).
|
||||
late_pending = self._pending_messages.pop(session_key, None)
|
||||
if late_pending is not None:
|
||||
logger.debug(
|
||||
"[%s] Late-arrival pending message during cleanup — spawning drain task",
|
||||
self.name,
|
||||
)
|
||||
_active = self._active_sessions.get(session_key)
|
||||
if _active is not None:
|
||||
_active.clear()
|
||||
drain_task = asyncio.create_task(
|
||||
self._process_message_background(late_pending, session_key)
|
||||
)
|
||||
# Hand ownership of the session to the drain task so stale-lock
|
||||
# detection keeps working while it runs.
|
||||
self._session_tasks[session_key] = drain_task
|
||||
try:
|
||||
self._background_tasks.add(drain_task)
|
||||
drain_task.add_done_callback(self._background_tasks.discard)
|
||||
except TypeError:
|
||||
# Tests stub create_task() with non-hashable sentinels; tolerate.
|
||||
pass
|
||||
current_task = asyncio.current_task()
|
||||
existing_task = self._session_tasks.get(session_key)
|
||||
if (
|
||||
existing_task is not None
|
||||
and existing_task is not current_task
|
||||
):
|
||||
# The in-band drain (or an earlier late-arrival drain)
|
||||
# already spawned a follow-up task that owns this
|
||||
# session. Re-queue the late-arrival event so that
|
||||
# task picks it up — avoids spawning two concurrent
|
||||
# _process_message_background tasks for the same key
|
||||
# (#17758 follow-up: prevents the create_task path
|
||||
# from racing with itself across the in-band/finally
|
||||
# boundary).
|
||||
self._pending_messages[session_key] = late_pending
|
||||
else:
|
||||
logger.debug(
|
||||
"[%s] Late-arrival pending message during cleanup — spawning drain task",
|
||||
self.name,
|
||||
)
|
||||
_active = self._active_sessions.get(session_key)
|
||||
if _active is not None:
|
||||
_active.clear()
|
||||
drain_task = asyncio.create_task(
|
||||
self._process_message_background(late_pending, session_key)
|
||||
)
|
||||
# Hand ownership of the session to the drain task so stale-lock
|
||||
# detection keeps working while it runs.
|
||||
self._session_tasks[session_key] = drain_task
|
||||
try:
|
||||
self._background_tasks.add(drain_task)
|
||||
drain_task.add_done_callback(self._background_tasks.discard)
|
||||
except TypeError:
|
||||
# Tests stub create_task() with non-hashable sentinels; tolerate.
|
||||
pass
|
||||
# Leave _active_sessions[session_key] populated — the drain
|
||||
# task's own lifecycle will clean it up.
|
||||
else:
|
||||
@@ -2703,16 +2888,34 @@ class BasePlatformAdapter(ABC):
|
||||
# reset-like command that already swapped in its own
|
||||
# command_guard (and cancelled us) can't be accidentally
|
||||
# cleared by our unwind. The command owns the session now.
|
||||
#
|
||||
# The owner-check also covers the in-band drain handoff
|
||||
# above: when we spawned a drain_task and transferred
|
||||
# ownership via ``_session_tasks[session_key] = drain_task``,
|
||||
# ``_session_tasks.get(session_key) is current_task`` is
|
||||
# False, so we leave _active_sessions populated. Without
|
||||
# this guard, the drain task picks up the same
|
||||
# interrupt_event in its own _process_message_background
|
||||
# entry, _release_session_guard's guard-match succeeds,
|
||||
# and we'd delete the entry while the drain task is still
|
||||
# running — letting a concurrent inbound message pass
|
||||
# the Level-1 guard and spawn a second handler for the
|
||||
# same session.
|
||||
current_task = asyncio.current_task()
|
||||
if current_task is not None and self._session_tasks.get(session_key) is current_task:
|
||||
del self._session_tasks[session_key]
|
||||
self._release_session_guard(session_key, guard=interrupt_event)
|
||||
self._release_session_guard(session_key, guard=interrupt_event)
|
||||
|
||||
async def cancel_background_tasks(self) -> None:
|
||||
"""Cancel any in-flight background message-processing tasks.
|
||||
|
||||
Used during gateway shutdown/replacement so active sessions from the old
|
||||
process do not keep running after adapters are being torn down.
|
||||
|
||||
Each cancelled task is awaited with a 5s bound so a wedged finally
|
||||
(typing-task cleanup, on_processing_complete hook) can't stall the
|
||||
whole shutdown path. Stragglers are released from our tracking and
|
||||
allowed to finish unwinding on their own.
|
||||
"""
|
||||
# Loop until no new tasks appear. Without this, a message
|
||||
# arriving during the `await asyncio.gather` below would spawn
|
||||
@@ -2731,7 +2934,21 @@ class BasePlatformAdapter(ABC):
|
||||
for task in tasks:
|
||||
self._expected_cancelled_tasks.add(task)
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
*(asyncio.shield(t) for t in tasks),
|
||||
return_exceptions=True,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"[%s] %d background task(s) did not exit within 5s; "
|
||||
"releasing tracking and letting them unwind in the background",
|
||||
self.name, len([t for t in tasks if not t.done()]),
|
||||
)
|
||||
break
|
||||
# Loop: late-arrival tasks spawned during the gather above
|
||||
# will be in self._background_tasks now. Re-check.
|
||||
self._background_tasks.clear()
|
||||
|
||||
@@ -18,7 +18,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
from typing import Callable, Dict, List, Optional, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1343,6 +1343,134 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
msg = await channel.send(content=caption if caption else None, file=file)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Discord message with multiple attachments.
|
||||
|
||||
Discord permits up to 10 file attachments per message. Batches are
|
||||
chunked accordingly. URL images are downloaded into memory and
|
||||
uploaded as inline attachments (same pattern as ``send_image`` so
|
||||
they render inline, not as bare links). Local files are opened
|
||||
directly. On per-chunk failure the remaining images in that chunk
|
||||
fall back to the base per-image loop.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
|
||||
try:
|
||||
import discord as _discord_mod
|
||||
import io as _io
|
||||
from urllib.parse import unquote as _unquote
|
||||
except Exception: # pragma: no cover
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
try:
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
if not channel:
|
||||
logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
CHUNK = 10
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
files: List[Any] = []
|
||||
captions: List[str] = []
|
||||
aiohttp_session = None
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
captions.append(alt_text)
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning("[%s] Skipping missing image: %s", self.name, local_path)
|
||||
continue
|
||||
files.append(_discord_mod.File(local_path, filename=os.path.basename(local_path)))
|
||||
else:
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("[%s] Blocked unsafe image URL in batch", self.name)
|
||||
continue
|
||||
# Download to BytesIO so it renders inline
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
if aiohttp_session is None:
|
||||
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
|
||||
async with aiohttp_session.get(
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30), **_req_kw,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(
|
||||
"[%s] Failed to download image (HTTP %d) in batch: %s",
|
||||
self.name, resp.status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
data = await resp.read()
|
||||
ct = resp.headers.get("content-type", "image/png")
|
||||
ext = "png"
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = "jpg"
|
||||
elif "gif" in ct:
|
||||
ext = "gif"
|
||||
elif "webp" in ct:
|
||||
ext = "webp"
|
||||
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
|
||||
except Exception as dl_err:
|
||||
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
|
||||
continue
|
||||
|
||||
if not files:
|
||||
continue
|
||||
|
||||
# Use the first caption if any (Discord only has one message body for the group)
|
||||
content = captions[0] if captions else None
|
||||
logger.info(
|
||||
"[%s] Sending %d image(s) as single Discord message (chunk %d/%d)",
|
||||
self.name, len(files), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
|
||||
if self._is_forum_parent(channel):
|
||||
await self._forum_post_file(
|
||||
channel,
|
||||
content=(content or "").strip(),
|
||||
files=files,
|
||||
)
|
||||
else:
|
||||
await channel.send(content=content, files=files)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s",
|
||||
self.name, chunk_idx + 1, len(chunks), e,
|
||||
exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
finally:
|
||||
if aiohttp_session is not None:
|
||||
try:
|
||||
await aiohttp_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def play_tts(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2270,6 +2398,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
async def slash_reload_mcp(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, "/reload-mcp")
|
||||
|
||||
@tree.command(name="reload-skills", description="Re-scan ~/.hermes/skills/ for new or removed skills")
|
||||
async def slash_reload_skills(interaction: discord.Interaction):
|
||||
await self._run_simple_slash(interaction, "/reload-skills")
|
||||
|
||||
@tree.command(name="voice", description="Toggle voice reply mode")
|
||||
@discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
|
||||
@discord.app_commands.choices(mode=[
|
||||
@@ -2906,6 +3038,43 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
confirm_id: str, metadata: Optional[dict] = None,
|
||||
) -> SendResult:
|
||||
"""Send a three-button slash-command confirmation prompt."""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
target_id = chat_id
|
||||
if metadata and metadata.get("thread_id"):
|
||||
target_id = metadata["thread_id"]
|
||||
|
||||
channel = self._client.get_channel(int(target_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(target_id))
|
||||
|
||||
# Embed description limit is 4096; message usually fits easily.
|
||||
max_desc = 4088
|
||||
body = message if len(message) <= max_desc else message[: max_desc - 3] + "..."
|
||||
embed = discord.Embed(
|
||||
title=title or "Confirm",
|
||||
description=body,
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
|
||||
view = SlashConfirmView(
|
||||
session_key=session_key,
|
||||
confirm_id=confirm_id,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
)
|
||||
|
||||
msg = await channel.send(embed=embed, view=view)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
@@ -3639,6 +3808,103 @@ if DISCORD_AVAILABLE:
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
class SlashConfirmView(discord.ui.View):
|
||||
"""Three-button view for generic slash-command confirmations.
|
||||
|
||||
Used by ``/reload-mcp`` and any future slash command routed through
|
||||
``GatewayRunner._request_slash_confirm``. Buttons map to the
|
||||
gateway's three choices:
|
||||
|
||||
* "Approve Once" → ``choice="once"``
|
||||
* "Always Approve" → ``choice="always"``
|
||||
* "Cancel" → ``choice="cancel"``
|
||||
|
||||
Clicking calls the module-level
|
||||
``tools.slash_confirm.resolve(session_key, confirm_id, choice)``
|
||||
which runs the handler the runner stored for this ``session_key``.
|
||||
Only users in the adapter's allowlist can click. Times out after
|
||||
5 minutes (matches the gateway primitive's timeout).
|
||||
"""
|
||||
|
||||
def __init__(self, session_key: str, confirm_id: str, allowed_user_ids: set):
|
||||
super().__init__(timeout=300)
|
||||
self.session_key = session_key
|
||||
self.confirm_id = confirm_id
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.resolved = False
|
||||
|
||||
def _check_auth(self, interaction: discord.Interaction) -> bool:
|
||||
if not self.allowed_user_ids:
|
||||
return True
|
||||
return str(interaction.user.id) in self.allowed_user_ids
|
||||
|
||||
async def _resolve(
|
||||
self, interaction: discord.Interaction, choice: str,
|
||||
color: discord.Color, label: str,
|
||||
):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"This prompt has already been resolved~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized to answer this prompt~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
self.resolved = True
|
||||
|
||||
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||
if embed:
|
||||
embed.color = color
|
||||
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
|
||||
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
|
||||
# Resolve via the module-level primitive. If the handler
|
||||
# returns a follow-up message, post it in the same channel.
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
result_text = await _slash_confirm_mod.resolve(
|
||||
self.session_key, self.confirm_id, choice,
|
||||
)
|
||||
if result_text:
|
||||
await interaction.followup.send(result_text)
|
||||
logger.info(
|
||||
"Discord button resolved slash-confirm for session %s "
|
||||
"(choice=%s, user=%s)",
|
||||
self.session_key, choice, interaction.user.display_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Discord slash-confirm resolve failed: %s", exc, exc_info=True)
|
||||
|
||||
@discord.ui.button(label="Approve Once", style=discord.ButtonStyle.green)
|
||||
async def approve_once(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button,
|
||||
):
|
||||
await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
|
||||
|
||||
@discord.ui.button(label="Always Approve", style=discord.ButtonStyle.blurple)
|
||||
async def approve_always(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button,
|
||||
):
|
||||
await self._resolve(interaction, "always", discord.Color.purple(), "Always approved")
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
|
||||
async def cancel(
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button,
|
||||
):
|
||||
await self._resolve(interaction, "cancel", discord.Color.greyple(), "Cancelled")
|
||||
|
||||
async def on_timeout(self):
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
class UpdatePromptView(discord.ui.View):
|
||||
"""Interactive Yes/No buttons for ``hermes update`` prompts.
|
||||
|
||||
|
||||
+108
-1
@@ -31,7 +31,7 @@ from email.mime.base import MIMEBase
|
||||
from email.utils import formatdate
|
||||
from email import encoders
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
@@ -540,6 +540,113 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
text += f"\n\nImage: {image_url}"
|
||||
return await self.send(chat_id, text.strip(), reply_to)
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single email with multiple MIME attachments.
|
||||
|
||||
Local files are attached directly. URL images have their URL
|
||||
appended to the body (email adapter does not download remote
|
||||
images). No hard cap — email clients handle dozens of
|
||||
attachments fine, subject to SMTP message size limits.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
body_parts: List[str] = []
|
||||
local_paths: List[str] = []
|
||||
for image_url, alt_text in images:
|
||||
if alt_text:
|
||||
body_parts.append(alt_text)
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if Path(local_path).exists():
|
||||
local_paths.append(local_path)
|
||||
else:
|
||||
logger.warning("[Email] Skipping missing image: %s", local_path)
|
||||
else:
|
||||
# Remote URLs just get linked in the body (parity with send_image)
|
||||
body_parts.append(f"Image: {image_url}")
|
||||
|
||||
if not local_paths and not body_parts:
|
||||
return
|
||||
|
||||
body = "\n\n".join(body_parts)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._send_email_with_attachments,
|
||||
chat_id,
|
||||
body,
|
||||
local_paths,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[Email] Multi-image send failed, falling back: %s", e, exc_info=True)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
|
||||
def _send_email_with_attachments(
|
||||
self,
|
||||
to_addr: str,
|
||||
body: str,
|
||||
file_paths: List[str],
|
||||
) -> str:
|
||||
"""Send an email with multiple file attachments via SMTP."""
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = self._address
|
||||
msg["To"] = to_addr
|
||||
|
||||
ctx = self._thread_context.get(to_addr, {})
|
||||
subject = ctx.get("subject", "Hermes Agent")
|
||||
if not subject.startswith("Re:"):
|
||||
subject = f"Re: {subject}"
|
||||
msg["Subject"] = subject
|
||||
|
||||
original_msg_id = ctx.get("message_id")
|
||||
if original_msg_id:
|
||||
msg["In-Reply-To"] = original_msg_id
|
||||
msg["References"] = original_msg_id
|
||||
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
|
||||
msg["Message-ID"] = msg_id
|
||||
|
||||
if body:
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
for file_path in file_paths:
|
||||
p = Path(file_path)
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(f.read())
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename={p.name}")
|
||||
msg.attach(part)
|
||||
except Exception as e:
|
||||
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
smtp.close()
|
||||
|
||||
logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths))
|
||||
return msg_id
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
@@ -496,6 +496,100 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Failed to post with file")
|
||||
return SendResult(success=True, message_id=data["id"])
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Mattermost post with multiple attachments.
|
||||
|
||||
Mattermost supports up to 5 ``file_ids`` per post. Each image is
|
||||
uploaded individually (Mattermost's file API is one-at-a-time),
|
||||
then a single post is created referencing all uploaded file_ids
|
||||
at once. Batches larger than 5 are chunked. Falls back to the
|
||||
base per-image loop on total failure.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
import mimetypes
|
||||
import aiohttp
|
||||
from urllib.parse import unquote as _unquote
|
||||
|
||||
CHUNK = 5 # Mattermost post file_ids cap
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
file_ids: List[str] = []
|
||||
caption_parts: List[str] = []
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
caption_parts.append(alt_text)
|
||||
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
p = Path(local_path)
|
||||
if not p.exists():
|
||||
logger.warning("Mattermost: skipping missing image %s", local_path)
|
||||
continue
|
||||
fname = p.name
|
||||
ct = mimetypes.guess_type(fname)[0] or "image/png"
|
||||
file_data = p.read_bytes()
|
||||
else:
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(image_url):
|
||||
logger.warning("Mattermost: blocked unsafe image URL in batch")
|
||||
continue
|
||||
try:
|
||||
async with self._session.get(
|
||||
image_url, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
logger.warning(
|
||||
"Mattermost: failed to download image (HTTP %d): %s",
|
||||
resp.status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
file_data = await resp.read()
|
||||
ct = resp.content_type or "image/png"
|
||||
except Exception as dl_err:
|
||||
logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err)
|
||||
continue
|
||||
fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{len(file_ids)}.png"
|
||||
|
||||
fid = await self._upload_file(chat_id, file_data, fname, ct)
|
||||
if fid:
|
||||
file_ids.append(fid)
|
||||
|
||||
if not file_ids:
|
||||
continue
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": "\n".join(caption_parts),
|
||||
"file_ids": file_ids,
|
||||
}
|
||||
logger.info(
|
||||
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
|
||||
len(file_ids), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
data = await self._api_post("posts", payload)
|
||||
if not data or "id" not in data:
|
||||
logger.warning("Mattermost: multi-image post failed, falling back")
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Mattermost: multi-image send failed (chunk %d/%d), falling back: %s",
|
||||
chunk_idx + 1, len(chunks), e, exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -976,6 +976,18 @@ class QQAdapter(BasePlatformAdapter):
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
# Apply group_policy ACL — guild channels are group-like contexts.
|
||||
# Without this check any member of any guild the bot is in could
|
||||
# bypass the configured allowlist.
|
||||
guild_id = str(d.get("guild_id", ""))
|
||||
author_id = str(author.get("id", ""))
|
||||
if not self._is_group_allowed(guild_id or channel_id, author_id):
|
||||
logger.debug(
|
||||
"[%s] Guild message blocked by ACL: channel=%s user=%s",
|
||||
self._log_tag, channel_id, author_id,
|
||||
)
|
||||
return
|
||||
|
||||
member = d.get("member") if isinstance(d.get("member"), dict) else {}
|
||||
nick = str(member.get("nick", "")) or str(author.get("username", ""))
|
||||
|
||||
@@ -1032,6 +1044,17 @@ class QQAdapter(BasePlatformAdapter):
|
||||
if not guild_id:
|
||||
return
|
||||
|
||||
# Apply dm_policy ACL — guild DMs were previously unauthenticated.
|
||||
# Without this check any member of any guild the bot is in could
|
||||
# bypass the configured allowlist via direct messages.
|
||||
author_id = str(author.get("id", ""))
|
||||
if not self._is_dm_allowed(author_id):
|
||||
logger.debug(
|
||||
"[%s] Guild DM blocked by ACL: guild=%s user=%s",
|
||||
self._log_tag, guild_id, author_id,
|
||||
)
|
||||
return
|
||||
|
||||
text = content
|
||||
att_result = await self._process_attachments(d.get("attachments"))
|
||||
image_urls = att_result["image_urls"]
|
||||
|
||||
+203
-4
@@ -21,7 +21,7 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import httpx
|
||||
@@ -39,6 +39,17 @@ from gateway.platforms.base import (
|
||||
cache_image_from_url,
|
||||
)
|
||||
from gateway.platforms.helpers import redact_phone
|
||||
from gateway.platforms.signal_rate_limit import (
|
||||
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD,
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG,
|
||||
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
SignalRateLimitError,
|
||||
_extract_retry_after_seconds,
|
||||
_format_wait,
|
||||
_is_signal_rate_limit_error,
|
||||
_signal_send_timeout,
|
||||
get_scheduler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +64,7 @@ SSE_RETRY_DELAY_MAX = 60.0
|
||||
HEALTH_CHECK_INTERVAL = 30.0 # seconds between health checks
|
||||
HEALTH_CHECK_STALE_THRESHOLD = 120.0 # seconds without SSE activity before concern
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -674,6 +686,8 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
rpc_id: str = None,
|
||||
*,
|
||||
log_failures: bool = True,
|
||||
raise_on_rate_limit: bool = False,
|
||||
timeout: float = 30.0,
|
||||
) -> Any:
|
||||
"""Send a JSON-RPC 2.0 request to signal-cli daemon.
|
||||
|
||||
@@ -682,6 +696,11 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
repeated NETWORK_FAILURE spam for unreachable recipients while
|
||||
still preserving visibility for the first occurrence and for
|
||||
unrelated RPCs.
|
||||
|
||||
When ``raise_on_rate_limit=True``, a Signal ``[429]`` /
|
||||
``RateLimitException`` response raises ``SignalRateLimitError``
|
||||
instead of being swallowed — lets callers (multi-attachment send)
|
||||
opt into backoff-retry without changing default behaviour.
|
||||
"""
|
||||
if not self.client:
|
||||
logger.warning("Signal: RPC called but client not connected")
|
||||
@@ -701,20 +720,28 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
resp = await self.client.post(
|
||||
f"{self.http_url}/api/v1/rpc",
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
if raise_on_rate_limit:
|
||||
if _is_signal_rate_limit_error(err):
|
||||
err_msg = str(err.get("message", "")) if isinstance(err, dict) else str(err)
|
||||
retry_after = _extract_retry_after_seconds(err)
|
||||
raise SignalRateLimitError(err_msg, retry_after=retry_after)
|
||||
if log_failures:
|
||||
logger.warning("Signal RPC error (%s): %s", method, data["error"])
|
||||
logger.warning("Signal RPC error (%s): %s", method, err)
|
||||
else:
|
||||
logger.debug("Signal RPC error (%s): %s", method, data["error"])
|
||||
logger.debug("Signal RPC error (%s): %s", method, err)
|
||||
return None
|
||||
|
||||
return data.get("result")
|
||||
|
||||
except SignalRateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if log_failures:
|
||||
logger.warning("Signal RPC %s failed: %s", method, e)
|
||||
@@ -978,6 +1005,178 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self._typing_failures.pop(chat_id, None)
|
||||
self._typing_skip_until.pop(chat_id, None)
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images via chunked Signal RPC calls.
|
||||
|
||||
Per-image alt texts are dropped — Signal's send RPC only carries
|
||||
one shared message body. Bad images (download failure, missing
|
||||
file, oversize) are skipped with a warning so one bad URL
|
||||
doesn't lose the rest of the batch. ``human_delay`` is ignored:
|
||||
the rate-limit scheduler handles inter-batch pacing.
|
||||
"""
|
||||
if not images:
|
||||
return
|
||||
|
||||
scheduler = get_scheduler()
|
||||
logger.info(
|
||||
"Signal send_multiple_images: received %d image(s) for %s — "
|
||||
"scheduler state: %s",
|
||||
len(images), chat_id[:30], scheduler.state(),
|
||||
)
|
||||
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
|
||||
attachments: List[str] = []
|
||||
skipped_download = 0
|
||||
skipped_missing = 0
|
||||
skipped_oversize = 0
|
||||
for image_url, _alt_text in images:
|
||||
if image_url.startswith("file://"):
|
||||
file_path = unquote(image_url[7:])
|
||||
else:
|
||||
try:
|
||||
file_path = await cache_image_from_url(image_url)
|
||||
except Exception as e:
|
||||
logger.warning("Signal: failed to download image %s: %s", image_url, e)
|
||||
skipped_download += 1
|
||||
continue
|
||||
|
||||
if not file_path or not Path(file_path).exists():
|
||||
logger.warning("Signal: image file not found for %s", image_url)
|
||||
skipped_missing += 1
|
||||
continue
|
||||
|
||||
file_size = Path(file_path).stat().st_size
|
||||
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
|
||||
logger.warning(
|
||||
"Signal: image too large (%d bytes), skipping %s", file_size, image_url
|
||||
)
|
||||
skipped_oversize += 1
|
||||
continue
|
||||
|
||||
attachments.append(file_path)
|
||||
|
||||
if not attachments:
|
||||
logger.error(
|
||||
"Signal: no valid images in batch of %d "
|
||||
"(download=%d missing=%d oversize=%d)",
|
||||
len(images), skipped_download, skipped_missing, skipped_oversize,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Signal send_multiple_images: %d/%d images valid, sending in chunks",
|
||||
len(attachments), len(images),
|
||||
)
|
||||
|
||||
base_params: Dict[str, Any] = {
|
||||
"account": self.account,
|
||||
"message": "",
|
||||
}
|
||||
if chat_id.startswith("group:"):
|
||||
base_params["groupId"] = chat_id[6:]
|
||||
else:
|
||||
base_params["recipient"] = [await self._resolve_recipient(chat_id)]
|
||||
|
||||
att_batches = [
|
||||
attachments[i:i + SIGNAL_MAX_ATTACHMENTS_PER_MSG]
|
||||
for i in range(0, len(attachments), SIGNAL_MAX_ATTACHMENTS_PER_MSG)
|
||||
]
|
||||
|
||||
for idx, att_batch in enumerate(att_batches):
|
||||
n = len(att_batch)
|
||||
estimated = scheduler.estimate_wait(n)
|
||||
logger.debug(
|
||||
"Signal batch %d/%d: %d attachments, estimated wait=%.1fs",
|
||||
idx + 1, len(att_batches), n, estimated,
|
||||
)
|
||||
if estimated >= SIGNAL_BATCH_PACING_NOTICE_THRESHOLD:
|
||||
await self._notify_batch_pacing(
|
||||
chat_id, idx + 1, len(att_batches), estimated
|
||||
)
|
||||
|
||||
params = dict(base_params, attachments=att_batch)
|
||||
send_timeout = _signal_send_timeout(n)
|
||||
|
||||
for attempt in range(1, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS + 1):
|
||||
await scheduler.acquire(n)
|
||||
try:
|
||||
_rpc_t0 = time.monotonic()
|
||||
result = await self._rpc(
|
||||
"send", params, raise_on_rate_limit=True, timeout=send_timeout,
|
||||
)
|
||||
_rpc_duration = time.monotonic() - _rpc_t0
|
||||
if result is not None:
|
||||
self._track_sent_timestamp(result)
|
||||
await scheduler.report_rpc_duration(_rpc_duration, n)
|
||||
logger.info(
|
||||
"Signal batch %d/%d: %d attachments sent in %.1fs "
|
||||
"(attempt %d/%d)",
|
||||
idx + 1, len(att_batches), n, _rpc_duration,
|
||||
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
)
|
||||
else:
|
||||
# Assume the server didn't accept the batch, don't deduce tokens
|
||||
logger.error(
|
||||
"Signal: RPC send failed for batch %d/%d (%d attachments, "
|
||||
"attempt %d/%d, rpc_duration=%.1fs)",
|
||||
idx + 1, len(att_batches), n,
|
||||
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
_rpc_duration,
|
||||
)
|
||||
# Retry transient (non-rate-limit) failures once
|
||||
if attempt < SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
backoff = 2.0 ** attempt
|
||||
logger.info(
|
||||
"Signal: retrying batch %d/%d after %.1fs backoff",
|
||||
idx + 1, len(att_batches), backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
break
|
||||
except SignalRateLimitError as e:
|
||||
scheduler.feedback(e.retry_after, n)
|
||||
if attempt >= SIGNAL_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
"Signal: rate-limit retries exhausted on batch %d/%d "
|
||||
"(%d attachments lost, server retry_after=%s)",
|
||||
idx + 1, len(att_batches), n,
|
||||
f"{e.retry_after:.0f}s" if e.retry_after else "unknown",
|
||||
)
|
||||
break
|
||||
logger.warning(
|
||||
"Signal: rate-limited on batch %d/%d "
|
||||
"(attempt %d/%d, server retry_after=%s); "
|
||||
"scheduler will pace the retry",
|
||||
idx + 1, len(att_batches),
|
||||
attempt, SIGNAL_RATE_LIMIT_MAX_ATTEMPTS,
|
||||
f"{e.retry_after:.0f}s" if e.retry_after else "unknown",
|
||||
)
|
||||
|
||||
async def _notify_batch_pacing(
|
||||
self,
|
||||
chat_id: str,
|
||||
next_batch_idx: int,
|
||||
total_batches: int,
|
||||
wait_s: float,
|
||||
) -> None:
|
||||
"""Inform the user when an inter-batch pacing wait crosses the
|
||||
notice threshold. Best-effort; logs and continues on failure."""
|
||||
try:
|
||||
await self.send(
|
||||
chat_id,
|
||||
f"(More images coming — pausing ~{_format_wait(wait_s)} "
|
||||
f"for Signal rate limit, batch {next_batch_idx}/{total_batches}.)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Signal: failed to send pacing notice: %s", e)
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Signal attachment rate-limit scheduler.
|
||||
|
||||
Process-wide token-bucket simulator that mirrors the per-account
|
||||
attachment rate limit signal-cli/Signal-Server enforce. Producers
|
||||
(``SignalAdapter.send_multiple_images`` and the ``send_message`` tool's
|
||||
Signal path) call ``acquire(n)`` before an attachment send; on a 429
|
||||
they call ``feedback(retry_after, n)`` so the model recalibrates from
|
||||
the server's authoritative hint.
|
||||
|
||||
The scheduler serializes concurrent calls through an ``asyncio.Lock``,
|
||||
giving FIFO fairness across agent sessions sharing one signal-cli
|
||||
daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code)
|
||||
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting
|
||||
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3
|
||||
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry
|
||||
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay
|
||||
SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SignalRateLimitError(Exception):
|
||||
"""
|
||||
Raised by ``SignalAdapter._rpc`` for rate-limit responses when the
|
||||
caller has opted in via ``raise_on_rate_limit=True``.
|
||||
|
||||
Carries the server-supplied per-token Retry-After (in seconds) on
|
||||
signal-cli ≥ v0.14.3
|
||||
``retry_after`` is None when the version doesn't expose it.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class SignalSchedulerError(Exception):
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection helpers — used to fish a 429 out of signal-cli's various error
|
||||
# shapes (typed code, [429] substring, libsignal-net RetryLaterException
|
||||
# leaked through AttachmentInvalidException).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# "Retry after 4 seconds" / "retry after 4 second" — libsignal-net's
|
||||
# RetryLaterException string form, surfaced when 429s hit during
|
||||
# attachment upload (signal-cli wraps these as AttachmentInvalidException
|
||||
# rather than RateLimitException, so the typed path doesn't fire).
|
||||
_RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_retry_after_seconds(err: Any) -> Optional[float]:
|
||||
"""Pull the per-token Retry-After window from a signal-cli rate-limit error.
|
||||
|
||||
Tries two sources, in order:
|
||||
1. ``error.data.response.results[*].retryAfterSeconds`` — the
|
||||
structured field signal-cli ≥ v0.14.3 surfaces for plain
|
||||
RateLimitException.
|
||||
2. ``"Retry after N seconds"`` parsed out of the message — covers
|
||||
libsignal-net's RetryLaterException that gets wrapped as
|
||||
AttachmentInvalidException during attachment upload, where the
|
||||
structured field stays null.
|
||||
|
||||
Returns None when neither yields a value.
|
||||
"""
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
data = err.get("data") or {}
|
||||
response = data.get("response") or {}
|
||||
results = response.get("results") or []
|
||||
candidates = [
|
||||
r.get("retryAfterSeconds") for r in results
|
||||
if isinstance(r, dict) and r.get("retryAfterSeconds")
|
||||
]
|
||||
if candidates:
|
||||
return float(max(candidates))
|
||||
msg = str(err.get("message", ""))
|
||||
else:
|
||||
msg = str(err)
|
||||
match = _RETRY_AFTER_RE.search(msg)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _is_signal_rate_limit_error(err: Any) -> bool:
|
||||
"""True if a signal-cli RPC error reflects a rate-limit failure.
|
||||
|
||||
Matches three layers:
|
||||
- typed ``RATELIMIT_ERROR`` code (signal-cli ≥ v0.14.3, plain
|
||||
RateLimitException)
|
||||
- legacy ``[429] / RateLimitException`` substrings
|
||||
- libsignal-net's ``RetryLaterException`` / ``Retry after N seconds``
|
||||
surfaced inside ``AttachmentInvalidException`` when the rate
|
||||
limit is hit during attachment upload — signal-cli never re-tags
|
||||
these as RateLimitException, so substring is the only signal.
|
||||
"""
|
||||
if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT:
|
||||
return True
|
||||
|
||||
message = (
|
||||
str(err.get("message", ""))
|
||||
if isinstance(err, dict)
|
||||
else str(err)
|
||||
)
|
||||
msg_lower = message.lower()
|
||||
return (
|
||||
"[429]" in message
|
||||
or "ratelimit" in msg_lower
|
||||
or "retrylaterexception" in msg_lower
|
||||
or "retry after" in msg_lower
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_wait(seconds: float) -> str:
|
||||
"""Human-friendly wait label for user-facing pacing notices."""
|
||||
s = max(0.0, seconds)
|
||||
if s < 90:
|
||||
return f"{int(round(s))}s"
|
||||
return f"{max(1, int(round(s / 60)))} min"
|
||||
|
||||
|
||||
def _signal_send_timeout(num_attachments: int) -> float:
|
||||
"""HTTP timeout for a Signal ``send`` RPC.
|
||||
|
||||
signal-cli uploads attachments serially during the call, so the
|
||||
server-side time scales with batch size. Default 30s is fine for
|
||||
text-only sends but truncates large attachment batches mid-upload —
|
||||
we then log a phantom failure even though signal-cli completes the
|
||||
send a few seconds later. Scale at 5s/attachment with a 60s floor.
|
||||
"""
|
||||
if num_attachments <= 0:
|
||||
return 30.0
|
||||
return max(60.0, 5.0 * num_attachments)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SignalAttachmentScheduler:
|
||||
"""Process-wide token-bucket simulator for Signal attachment sends.
|
||||
|
||||
The bucket holds up to ``capacity`` tokens (default 50, matching
|
||||
Signal's server-side rate-limit bucket size). Each attachment consumes one
|
||||
token. Tokens refill at ``refill_rate`` tokens/second, calibrated
|
||||
from the per-token Retry-After hint we get from the server when a
|
||||
429 fires. Until we've observed one, we use the documented default
|
||||
(1 token / 4 seconds).
|
||||
|
||||
Concurrent ``acquire(n)`` calls serialize through an
|
||||
``asyncio.Lock`` — natural FIFO across agent sessions hitting the
|
||||
same daemon.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
|
||||
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER),
|
||||
) -> None:
|
||||
self.capacity = float(capacity)
|
||||
self.tokens = float(capacity)
|
||||
self.refill_rate = 1.0 / float(default_retry_after)
|
||||
self.last_refill = time.monotonic()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refill(self) -> None:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
if elapsed > 0 and self.tokens < self.capacity:
|
||||
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
|
||||
self.last_refill = now
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def estimate_wait(self, n: int) -> float:
|
||||
"""Best-effort estimate of the seconds until ``n`` tokens would
|
||||
be available. Used to decide whether to emit a user-facing
|
||||
pacing notice *before* committing to an ``acquire`` that may
|
||||
block silently. Lock-free; small races vs. concurrent acquires
|
||||
are benign for an informational notice.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
projected = self.tokens
|
||||
if elapsed > 0 and projected < self.capacity:
|
||||
projected = min(self.capacity, projected + elapsed * self.refill_rate)
|
||||
deficit = n - projected
|
||||
if deficit <= 0:
|
||||
return 0.0
|
||||
return deficit / self.refill_rate
|
||||
|
||||
async def acquire(self, n: int) -> float:
|
||||
"""Block until at least ``n`` tokens are available, return the
|
||||
seconds slept.
|
||||
|
||||
Does **not** deduct tokens — the bucket is a read-only model of
|
||||
server-side capacity. Call ``report_rpc_duration()`` after the
|
||||
RPC to synchronise the model with the server timeline.
|
||||
|
||||
Not perfect in case lots of coroutines try to acquire for big
|
||||
uploads (``report_rpc_duration`` will take a long time to get hit)
|
||||
but this is just a simulation. Signal server is ground truth and
|
||||
will raise rate-limit exceptions triggering requeues.
|
||||
|
||||
The lock is released during ``asyncio.sleep`` so other callers
|
||||
can interleave. A retry loop re-checks after each sleep in
|
||||
case the deadline was pessimistic.
|
||||
"""
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
if n > self.capacity:
|
||||
raise SignalSchedulerError(
|
||||
f"Signal scheduler was called requesting {n} tokens "
|
||||
f"(max is {self.capacity})",
|
||||
)
|
||||
|
||||
total_slept = 0.0
|
||||
first_pass = True
|
||||
while True:
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
if self.tokens >= n:
|
||||
if not first_pass or total_slept > 0:
|
||||
logger.debug(
|
||||
"Signal scheduler: tokens sufficient for %d "
|
||||
"(remaining=%.1f, total_slept=%.1fs)",
|
||||
n, self.tokens, total_slept,
|
||||
)
|
||||
return total_slept
|
||||
deficit = n - self.tokens
|
||||
wait = deficit / self.refill_rate
|
||||
if first_pass:
|
||||
logger.info(
|
||||
"Signal scheduler: pausing %.1fs for %d tokens "
|
||||
"(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)",
|
||||
wait, n, self.tokens, deficit,
|
||||
self.refill_rate, 1.0 / self.refill_rate,
|
||||
)
|
||||
first_pass = False
|
||||
await asyncio.sleep(wait)
|
||||
total_slept += wait
|
||||
|
||||
async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None:
|
||||
"""Record an attachment-send RPC that just completed.
|
||||
|
||||
Deducts ``n_attachments`` tokens without crediting refill during
|
||||
the upload window. Signal's server checks the bucket at RPC start
|
||||
and does *not* refill during request processing — refill resumes
|
||||
after the response. Crediting upload-time refill causes cumulative
|
||||
drift that eventually triggers 429s.
|
||||
|
||||
Advances ``last_refill`` so the next ``acquire`` / ``_refill``
|
||||
starts counting from this point.
|
||||
"""
|
||||
if n_attachments <= 0:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
token_before = self.tokens
|
||||
self.tokens = max(0.0, token_before - float(n_attachments))
|
||||
self.last_refill = now
|
||||
logger.log(
|
||||
logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
|
||||
"Signal scheduler: RPC for %d att took %.1fs — "
|
||||
"tokens %.1f → %.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)",
|
||||
n_attachments, rpc_duration,
|
||||
token_before, self.tokens,
|
||||
n_attachments, self.refill_rate,
|
||||
)
|
||||
|
||||
def feedback(self, retry_after: Optional[float], n_attempted: int) -> None:
|
||||
"""Apply server feedback after a 429.
|
||||
|
||||
``retry_after`` is the per-*token* refill window the server
|
||||
reports (None when signal-cli is older than v0.14.3 and didn't
|
||||
surface it).
|
||||
|
||||
When present we calibrate ``refill_rate`` from it:
|
||||
the server is authoritative.
|
||||
"""
|
||||
if retry_after and retry_after > 0:
|
||||
new_rate = 1.0 / float(retry_after)
|
||||
if new_rate != self.refill_rate:
|
||||
logger.info(
|
||||
"Signal scheduler: calibrating refill_rate to %.4f tokens/sec "
|
||||
"(server retry_after=%.1fs per token)",
|
||||
new_rate, retry_after,
|
||||
)
|
||||
self.refill_rate = new_rate
|
||||
self.tokens = 0.0
|
||||
self.last_refill = time.monotonic()
|
||||
|
||||
def state(self) -> dict:
|
||||
"""Return current scheduler state for diagnostic logging (read-only).
|
||||
|
||||
Does not advance ``last_refill`` — safe to call from logging paths
|
||||
without perturbing the bucket.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
projected = self.tokens
|
||||
if elapsed > 0 and projected < self.capacity:
|
||||
projected = min(self.capacity, projected + elapsed * self.refill_rate)
|
||||
return {
|
||||
"tokens": round(projected, 1),
|
||||
"capacity": int(self.capacity),
|
||||
"refill_rate": round(self.refill_rate, 4),
|
||||
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-wide singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_scheduler: Optional[SignalAttachmentScheduler] = None
|
||||
|
||||
|
||||
def get_scheduler() -> SignalAttachmentScheduler:
|
||||
"""Return the process-wide scheduler, creating it on first access."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = SignalAttachmentScheduler()
|
||||
logger.info(
|
||||
"Signal scheduler: created (capacity=%d tokens, refill=%.4f/s ≈ %.1fs/token)",
|
||||
int(_scheduler.capacity),
|
||||
_scheduler.refill_rate,
|
||||
1.0 / _scheduler.refill_rate,
|
||||
)
|
||||
return _scheduler
|
||||
|
||||
|
||||
def _reset_scheduler() -> None:
|
||||
"""Drop the cached scheduler so the next ``get_scheduler`` call
|
||||
builds a fresh one. Test-only — never call from production paths."""
|
||||
global _scheduler
|
||||
_scheduler = None
|
||||
@@ -514,6 +514,15 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
):
|
||||
self._app.action(_action_id)(self._handle_approval_action)
|
||||
|
||||
# Register Block Kit action handlers for slash-confirm buttons
|
||||
# (generic three-option prompts; see tools/slash_confirm.py).
|
||||
for _action_id in (
|
||||
"hermes_confirm_once",
|
||||
"hermes_confirm_always",
|
||||
"hermes_confirm_cancel",
|
||||
):
|
||||
self._app.action(_action_id)(self._handle_slash_confirm_action)
|
||||
|
||||
# Start Socket Mode handler in background
|
||||
self._handler = AsyncSocketModeHandler(self._app, app_token, proxy=proxy_url)
|
||||
_apply_slack_proxy(self._handler.client, proxy_url)
|
||||
@@ -783,6 +792,111 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
raise last_exc
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[Tuple[str, str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images as a single Slack message with multiple file uploads.
|
||||
|
||||
Uses ``files_upload_v2`` with its ``file_uploads`` parameter so all
|
||||
images show up attached to one ``initial_comment`` message instead
|
||||
of N separate messages. Falls back to the base per-image loop on
|
||||
any failure.
|
||||
|
||||
The batch limit is 10 file uploads per call (Slack server-side cap).
|
||||
"""
|
||||
if not self._app:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx as _httpx
|
||||
from urllib.parse import unquote as _unquote
|
||||
from tools.url_safety import is_safe_url as _is_safe_url
|
||||
except Exception:
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
|
||||
CHUNK = 10
|
||||
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
file_uploads: List[Dict[str, Any]] = []
|
||||
initial_comment_parts: List[str] = []
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http_client:
|
||||
for image_url, alt_text in chunk:
|
||||
if alt_text:
|
||||
initial_comment_parts.append(alt_text)
|
||||
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning("[Slack] Skipping missing image: %s", local_path)
|
||||
continue
|
||||
file_uploads.append({
|
||||
"file": local_path,
|
||||
"filename": os.path.basename(local_path),
|
||||
})
|
||||
else:
|
||||
if not _is_safe_url(image_url):
|
||||
logger.warning("[Slack] Blocked unsafe image URL in batch")
|
||||
continue
|
||||
try:
|
||||
response = await http_client.get(image_url)
|
||||
response.raise_for_status()
|
||||
ext = "png"
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = "jpg"
|
||||
elif "gif" in ct:
|
||||
ext = "gif"
|
||||
elif "webp" in ct:
|
||||
ext = "webp"
|
||||
file_uploads.append({
|
||||
"content": response.content,
|
||||
"filename": f"image_{len(file_uploads)}.{ext}",
|
||||
})
|
||||
except Exception as dl_err:
|
||||
logger.warning(
|
||||
"[Slack] Download failed for %s: %s",
|
||||
safe_url_for_log(image_url), dl_err,
|
||||
)
|
||||
continue
|
||||
|
||||
if not file_uploads:
|
||||
continue
|
||||
|
||||
initial_comment = "\n".join(initial_comment_parts) if initial_comment_parts else ""
|
||||
logger.info(
|
||||
"[Slack] Sending %d image(s) in single files_upload_v2 (chunk %d/%d)",
|
||||
len(file_uploads), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
result = await self._get_client(chat_id).files_upload_v2(
|
||||
channel=chat_id,
|
||||
file_uploads=file_uploads,
|
||||
initial_comment=initial_comment,
|
||||
thread_ts=thread_ts,
|
||||
)
|
||||
self._record_uploaded_file_thread(chat_id, thread_ts)
|
||||
_ = result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[Slack] Multi-image files_upload_v2 failed (chunk %d/%d), falling back to per-image: %s",
|
||||
chunk_idx + 1, len(chunks), e,
|
||||
exc_info=True,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
|
||||
|
||||
def _record_uploaded_file_thread(self, chat_id: str, thread_ts: Optional[str]) -> None:
|
||||
"""Treat successful file uploads as bot participation in a thread."""
|
||||
if not thread_ts:
|
||||
@@ -1931,6 +2045,168 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
logger.error("[Slack] send_exec_approval failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a Block Kit three-option slash-command confirmation prompt."""
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
body = message[:2900] + "..." if len(message) > 2900 else message
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
# Encode session_key and confirm_id into the button value so the
|
||||
# callback handler can resolve without extra bookkeeping.
|
||||
value = f"{session_key}|{confirm_id}"
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Approve Once"},
|
||||
"style": "primary",
|
||||
"action_id": "hermes_confirm_once",
|
||||
"value": value,
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Always Approve"},
|
||||
"action_id": "hermes_confirm_always",
|
||||
"value": value,
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Cancel"},
|
||||
"style": "danger",
|
||||
"action_id": "hermes_confirm_cancel",
|
||||
"value": value,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"channel": chat_id,
|
||||
"text": f"{title or 'Confirm'}: {body[:100]}",
|
||||
"blocks": blocks,
|
||||
}
|
||||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
|
||||
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||
return SendResult(success=True, message_id=result.get("ts", ""), raw_response=result)
|
||||
except Exception as e:
|
||||
logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def _handle_slash_confirm_action(self, ack, body, action) -> None:
|
||||
"""Handle a slash-confirm button click from Block Kit."""
|
||||
await ack()
|
||||
|
||||
action_id = action.get("action_id", "")
|
||||
value = action.get("value", "")
|
||||
message = body.get("message", {})
|
||||
msg_ts = message.get("ts", "")
|
||||
channel_id = body.get("channel", {}).get("id", "")
|
||||
user_name = body.get("user", {}).get("name", "unknown")
|
||||
user_id = body.get("user", {}).get("id", "")
|
||||
|
||||
# Authorization — reuse the exec-approval allowlist.
|
||||
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
|
||||
if allowed_csv:
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and user_id not in allowed_ids:
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized slash-confirm click by %s (%s) — ignoring",
|
||||
user_name, user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Parse session_key|confirm_id back out
|
||||
if "|" not in value:
|
||||
logger.warning("[Slack] Malformed slash-confirm value: %s", value)
|
||||
return
|
||||
session_key, confirm_id = value.split("|", 1)
|
||||
|
||||
choice_map = {
|
||||
"hermes_confirm_once": "once",
|
||||
"hermes_confirm_always": "always",
|
||||
"hermes_confirm_cancel": "cancel",
|
||||
}
|
||||
choice = choice_map.get(action_id, "cancel")
|
||||
|
||||
label_map = {
|
||||
"once": f"✅ Approved once by {user_name}",
|
||||
"always": f"🔒 Always approved by {user_name}",
|
||||
"cancel": f"❌ Cancelled by {user_name}",
|
||||
}
|
||||
decision_text = label_map.get(choice, f"Resolved by {user_name}")
|
||||
|
||||
# Pull original prompt body out of the section block so we can show
|
||||
# the decision inline without losing context.
|
||||
original_text = ""
|
||||
for block in message.get("blocks", []):
|
||||
if block.get("type") == "section":
|
||||
original_text = block.get("text", {}).get("text", "")
|
||||
break
|
||||
|
||||
updated_blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": original_text or "Confirmation prompt",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{"type": "mrkdwn", "text": decision_text},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
await self._get_client(channel_id).chat_update(
|
||||
channel=channel_id,
|
||||
ts=msg_ts,
|
||||
text=decision_text,
|
||||
blocks=updated_blocks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[Slack] Failed to update slash-confirm message: %s", e)
|
||||
|
||||
# Resolve via the module-level primitive and post any follow-up.
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice)
|
||||
if result_text:
|
||||
post_kwargs: Dict[str, Any] = {
|
||||
"channel": channel_id,
|
||||
"text": result_text,
|
||||
}
|
||||
# Inherit the thread so the reply stays in the same place.
|
||||
thread_ts = message.get("thread_ts") or msg_ts
|
||||
if thread_ts:
|
||||
post_kwargs["thread_ts"] = thread_ts
|
||||
await self._get_client(channel_id).chat_postMessage(**post_kwargs)
|
||||
logger.info(
|
||||
"Slack button resolved slash-confirm for session %s (choice=%s, user=%s)",
|
||||
session_key, choice, user_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve slash-confirm from Slack button: %s", exc, exc_info=True)
|
||||
|
||||
async def _handle_approval_action(self, ack, body, action) -> None:
|
||||
"""Handle an approval button click from Block Kit."""
|
||||
await ack()
|
||||
|
||||
+245
-16
@@ -237,14 +237,14 @@ def _wrap_markdown_tables(text: str) -> str:
|
||||
class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
Telegram bot adapter.
|
||||
|
||||
|
||||
Handles:
|
||||
- Receiving messages from users and groups
|
||||
- Sending responses with Telegram markdown
|
||||
- Forum topics (thread_id support)
|
||||
- Media messages
|
||||
"""
|
||||
|
||||
|
||||
# Telegram message limits
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
@@ -252,7 +252,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
||||
_GENERAL_TOPIC_THREAD_ID = "1"
|
||||
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.TELEGRAM)
|
||||
self._app: Optional[Application] = None
|
||||
@@ -286,6 +286,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._model_picker_state: Dict[str, dict] = {}
|
||||
# Approval button state: message_id → session_key
|
||||
self._approval_state: Dict[int, str] = {}
|
||||
# Slash-confirm button state: confirm_id → session_key (for /reload-mcp
|
||||
# and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
|
||||
self._slash_confirm_state: Dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _is_callback_user_authorized(user_id: str) -> bool:
|
||||
@@ -994,7 +997,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._set_fatal_error("telegram_connect_error", message, retryable=True)
|
||||
logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
|
||||
pending_media_group_tasks = list(self._media_group_tasks.values())
|
||||
@@ -1411,6 +1414,48 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_slash_confirm(
|
||||
self, chat_id: str, title: str, message: str, session_key: str,
|
||||
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Render a three-button slash-command confirmation prompt."""
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
# Message body: render as plain text (message already contains
|
||||
# markdown formatting from the gateway primitive).
|
||||
preview = message if len(message) <= 3800 else message[:3800] + "..."
|
||||
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
|
||||
InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}"),
|
||||
],
|
||||
])
|
||||
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"text": preview,
|
||||
"parse_mode": ParseMode.MARKDOWN,
|
||||
"reply_markup": keyboard,
|
||||
**self._link_preview_kwargs(),
|
||||
}
|
||||
message_thread_id = self._message_thread_id_for_send(thread_id)
|
||||
if message_thread_id is not None:
|
||||
kwargs["message_thread_id"] = message_thread_id
|
||||
|
||||
msg = await self._bot.send_message(**kwargs)
|
||||
self._slash_confirm_state[confirm_id] = session_key
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_model_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1779,6 +1824,68 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
|
||||
return
|
||||
|
||||
# --- Slash-confirm callbacks (sc:choice:confirm_id) ---
|
||||
if data.startswith("sc:"):
|
||||
parts = data.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
choice = parts[1] # once, always, cancel
|
||||
confirm_id = parts[2]
|
||||
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
if not self._is_callback_user_authorized(caller_id):
|
||||
await query.answer(text="⛔ You are not authorized to answer this prompt.")
|
||||
return
|
||||
|
||||
session_key = self._slash_confirm_state.pop(confirm_id, None)
|
||||
if not session_key:
|
||||
await query.answer(text="This prompt has already been resolved.")
|
||||
return
|
||||
|
||||
label_map = {
|
||||
"once": "✅ Approved once",
|
||||
"always": "🔒 Always approve",
|
||||
"cancel": "❌ Cancelled",
|
||||
}
|
||||
user_display = getattr(query.from_user, "first_name", "User")
|
||||
label = label_map.get(choice, "Resolved")
|
||||
|
||||
await query.answer(text=label)
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=f"{label} by {user_display}",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve via the module-level primitive. The runner stored
|
||||
# a handler keyed by session_key; we run it on the event
|
||||
# loop and (if it returns a string) send it as a follow-up
|
||||
# message in the same chat.
|
||||
try:
|
||||
from tools import slash_confirm as _slash_confirm_mod
|
||||
result_text = await _slash_confirm_mod.resolve(
|
||||
session_key, confirm_id, choice,
|
||||
)
|
||||
if result_text and query.message:
|
||||
# Inherit the prompt message's thread so the reply
|
||||
# lands in the same supergroup topic / reply chain.
|
||||
thread_id = getattr(query.message, "message_thread_id", None)
|
||||
send_kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(query.message.chat_id),
|
||||
"text": result_text,
|
||||
"parse_mode": ParseMode.MARKDOWN,
|
||||
**self._link_preview_kwargs(),
|
||||
}
|
||||
if thread_id is not None:
|
||||
send_kwargs["message_thread_id"] = thread_id
|
||||
await self._bot.send_message(**send_kwargs)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
|
||||
return
|
||||
|
||||
# --- Update prompt callbacks ---
|
||||
if not data.startswith("update_prompt:"):
|
||||
return
|
||||
@@ -1844,8 +1951,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
|
||||
|
||||
with open(audio_path, "rb") as audio_file:
|
||||
# .ogg files -> send as voice (round playable bubble)
|
||||
if audio_path.endswith((".ogg", ".opus")):
|
||||
ext = os.path.splitext(audio_path)[1].lower()
|
||||
# .ogg / .opus files -> send as voice (round playable bubble)
|
||||
if ext in (".ogg", ".opus"):
|
||||
_voice_thread = self._metadata_thread_id(metadata)
|
||||
msg = await self._bot.send_voice(
|
||||
chat_id=int(chat_id),
|
||||
@@ -1854,8 +1962,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
reply_to_message_id=int(reply_to) if reply_to else None,
|
||||
message_thread_id=self._message_thread_id_for_send(_voice_thread),
|
||||
)
|
||||
else:
|
||||
# .mp3 and others -> send as audio file
|
||||
elif ext in (".mp3", ".m4a"):
|
||||
# Telegram's Bot API sendAudio only accepts MP3 / M4A.
|
||||
_audio_thread = self._metadata_thread_id(metadata)
|
||||
msg = await self._bot.send_audio(
|
||||
chat_id=int(chat_id),
|
||||
@@ -1864,6 +1972,16 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
reply_to_message_id=int(reply_to) if reply_to else None,
|
||||
message_thread_id=self._message_thread_id_for_send(_audio_thread),
|
||||
)
|
||||
else:
|
||||
# Formats Telegram can't play natively (.wav, .flac, ...)
|
||||
# — fall back to document delivery instead of raising.
|
||||
return await self.send_document(
|
||||
chat_id=chat_id,
|
||||
file_path=audio_path,
|
||||
caption=caption,
|
||||
reply_to=reply_to,
|
||||
metadata=metadata,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -1873,7 +1991,118 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
exc_info=True,
|
||||
)
|
||||
return await super().send_voice(chat_id, audio_path, caption, reply_to)
|
||||
|
||||
|
||||
async def send_multiple_images(
|
||||
self,
|
||||
chat_id: str,
|
||||
images: List[tuple],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
human_delay: float = 0.0,
|
||||
) -> None:
|
||||
"""Send a batch of images natively via Telegram's media group API.
|
||||
|
||||
Telegram's ``send_media_group`` bundles up to 10 photos/videos into
|
||||
a single album. Larger batches are chunked. Animated GIFs cannot
|
||||
go into a media group (they require ``send_animation``), so they
|
||||
are peeled off and sent individually via the base default path.
|
||||
|
||||
URL-based photos go into the group directly; local files are
|
||||
opened as byte streams. On failure the whole batch falls back to
|
||||
the base adapter's per-image loop.
|
||||
"""
|
||||
if not self._bot:
|
||||
return
|
||||
if not images:
|
||||
return
|
||||
|
||||
try:
|
||||
from telegram import InputMediaPhoto
|
||||
except Exception as exc: # pragma: no cover - missing SDK
|
||||
logger.warning(
|
||||
"[%s] InputMediaPhoto unavailable, falling back to per-image send: %s",
|
||||
self.name, exc,
|
||||
)
|
||||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||||
return
|
||||
|
||||
# Peel off animations — they need send_animation, not send_media_group
|
||||
animations: List[tuple] = []
|
||||
photos: List[tuple] = []
|
||||
for image_url, alt_text in images:
|
||||
if not image_url.startswith("file://") and self._is_animation_url(image_url):
|
||||
animations.append((image_url, alt_text))
|
||||
else:
|
||||
photos.append((image_url, alt_text))
|
||||
|
||||
# Animations: route through the base default (per-image send_animation)
|
||||
if animations:
|
||||
await super().send_multiple_images(
|
||||
chat_id, animations, metadata, human_delay=human_delay,
|
||||
)
|
||||
|
||||
if not photos:
|
||||
return
|
||||
|
||||
from urllib.parse import unquote as _unquote
|
||||
_thread = self._metadata_thread_id(metadata)
|
||||
_thread_id = self._message_thread_id_for_send(_thread)
|
||||
|
||||
# Chunk into groups of 10 (Telegram's album limit)
|
||||
CHUNK = 10
|
||||
chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if human_delay > 0 and chunk_idx > 0:
|
||||
await asyncio.sleep(human_delay)
|
||||
|
||||
media: List[Any] = []
|
||||
opened_files: List[Any] = []
|
||||
try:
|
||||
for image_url, alt_text in chunk:
|
||||
caption = alt_text[:1024] if alt_text else None
|
||||
if image_url.startswith("file://"):
|
||||
local_path = _unquote(image_url[7:])
|
||||
if not os.path.exists(local_path):
|
||||
logger.warning(
|
||||
"[%s] Skipping missing image in media group: %s",
|
||||
self.name, local_path,
|
||||
)
|
||||
continue
|
||||
fh = open(local_path, "rb")
|
||||
opened_files.append(fh)
|
||||
media.append(InputMediaPhoto(media=fh, caption=caption))
|
||||
else:
|
||||
media.append(InputMediaPhoto(media=image_url, caption=caption))
|
||||
|
||||
if not media:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"[%s] Sending media group of %d photo(s) (chunk %d/%d)",
|
||||
self.name, len(media), chunk_idx + 1, len(chunks),
|
||||
)
|
||||
await self._bot.send_media_group(
|
||||
chat_id=int(chat_id),
|
||||
media=media,
|
||||
message_thread_id=_thread_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s",
|
||||
self.name, chunk_idx + 1, len(chunks), e,
|
||||
exc_info=True,
|
||||
)
|
||||
# Fallback: send each photo in this chunk individually
|
||||
await super().send_multiple_images(
|
||||
chat_id, chunk, metadata, human_delay=human_delay,
|
||||
)
|
||||
finally:
|
||||
for fh in opened_files:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2040,7 +2269,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
# Final fallback: send URL as text
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
|
||||
|
||||
async def send_animation(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2102,7 +2331,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Get information about a Telegram chat."""
|
||||
if not self._bot:
|
||||
@@ -2136,7 +2365,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
exc_info=True,
|
||||
)
|
||||
return {"name": str(chat_id), "type": "dm", "error": str(e)}
|
||||
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""
|
||||
Convert standard markdown to Telegram MarkdownV2 format.
|
||||
@@ -2308,7 +2537,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
text = ''.join(_safe_parts)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# ── Group mention gating ──────────────────────────────────────────────
|
||||
|
||||
def _telegram_require_mention(self) -> bool:
|
||||
@@ -2523,7 +2752,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
event = self._build_message_event(update.message, MessageType.TEXT, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
|
||||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming command messages."""
|
||||
if not update.message or not update.message.text:
|
||||
@@ -2533,7 +2762,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.COMMAND, update_id=update.update_id)
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming location/venue pin messages."""
|
||||
if not update.message:
|
||||
@@ -2891,7 +3120,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
|
||||
"""Buffer Telegram media-group items so albums arrive as one logical event.
|
||||
|
||||
|
||||
@@ -202,26 +202,22 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
if deliver_type == "github_comment":
|
||||
return await self._deliver_github_comment(content, delivery)
|
||||
|
||||
# Cross-platform delivery — any platform with a gateway adapter
|
||||
if self.gateway_runner and deliver_type in (
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"sms",
|
||||
"whatsapp",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"homeassistant",
|
||||
"email",
|
||||
"dingtalk",
|
||||
"feishu",
|
||||
"wecom",
|
||||
"wecom_callback",
|
||||
"weixin",
|
||||
"bluebubbles",
|
||||
"qqbot",
|
||||
):
|
||||
# Cross-platform delivery — any platform with a gateway adapter.
|
||||
# Check both built-in names and plugin-registered platforms.
|
||||
_BUILTIN_DELIVER_PLATFORMS = {
|
||||
"telegram", "discord", "slack", "signal", "sms", "whatsapp",
|
||||
"matrix", "mattermost", "homeassistant", "email", "dingtalk",
|
||||
"feishu", "wecom", "wecom_callback", "weixin", "bluebubbles",
|
||||
"qqbot", "yuanbao",
|
||||
}
|
||||
_is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS
|
||||
if not _is_known_platform:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
_is_known_platform = platform_registry.is_registered(deliver_type)
|
||||
except Exception:
|
||||
pass
|
||||
if self.gateway_runner and _is_known_platform:
|
||||
return await self._deliver_cross_platform(
|
||||
deliver_type, content, delivery
|
||||
)
|
||||
|
||||
@@ -1620,7 +1620,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
_, image_cleaned = self.extract_images(cleaned_content)
|
||||
local_files, final_content = self.extract_local_files(image_cleaned)
|
||||
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"}
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
|
||||
|
||||
+724
-94
File diff suppressed because it is too large
Load Diff
+24
-13
@@ -62,6 +62,7 @@ from .config import (
|
||||
)
|
||||
from .whatsapp_identity import (
|
||||
canonical_whatsapp_identifier,
|
||||
normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers
|
||||
)
|
||||
from utils import atomic_replace
|
||||
|
||||
@@ -234,7 +235,7 @@ def build_session_context_prompt(
|
||||
) -> str:
|
||||
"""
|
||||
Build the dynamic system prompt section that tells the agent about its context.
|
||||
|
||||
|
||||
This is injected into the system prompt so the agent knows:
|
||||
- Where messages are coming from
|
||||
- What platforms are connected
|
||||
@@ -246,13 +247,23 @@ def build_session_context_prompt(
|
||||
Platforms like Discord are excluded because mentions need real IDs.
|
||||
Routing still uses the original values (they stay in SessionSource).
|
||||
"""
|
||||
# Only apply redaction on platforms where IDs aren't needed for mentions
|
||||
redact_pii = redact_pii and context.source.platform in _PII_SAFE_PLATFORMS
|
||||
# Only apply redaction on platforms where IDs aren't needed for mentions.
|
||||
# Check both the hardcoded set (builtins) and the plugin registry.
|
||||
_is_pii_safe = context.source.platform in _PII_SAFE_PLATFORMS
|
||||
if not _is_pii_safe:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(context.source.platform.value)
|
||||
if entry and entry.pii_safe:
|
||||
_is_pii_safe = True
|
||||
except Exception:
|
||||
pass
|
||||
redact_pii = redact_pii and _is_pii_safe
|
||||
lines = [
|
||||
"## Current Session Context",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
# Source info
|
||||
platform_name = context.source.platform.value.title()
|
||||
if context.source.platform == Platform.LOCAL:
|
||||
@@ -277,7 +288,7 @@ def build_session_context_prompt(
|
||||
else:
|
||||
desc = src.description
|
||||
lines.append(f"**Source:** {platform_name} ({desc})")
|
||||
|
||||
|
||||
# Channel topic (if available - provides context about the channel's purpose)
|
||||
if context.source.chat_topic:
|
||||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
@@ -302,7 +313,7 @@ def build_session_context_prompt(
|
||||
if redact_pii:
|
||||
uid = _hash_sender_id(uid)
|
||||
lines.append(f"**User ID:** {uid}")
|
||||
|
||||
|
||||
# Platform-specific behavioral notes
|
||||
if context.source.platform == Platform.SLACK:
|
||||
lines.append("")
|
||||
@@ -368,9 +379,9 @@ def build_session_context_prompt(
|
||||
for p in context.connected_platforms:
|
||||
if p != Platform.LOCAL:
|
||||
platforms_list.append(f"{p.value}: Connected ✓")
|
||||
|
||||
|
||||
lines.append(f"**Connected Platforms:** {', '.join(platforms_list)}")
|
||||
|
||||
|
||||
# Home channels
|
||||
if context.home_channels:
|
||||
lines.append("")
|
||||
@@ -378,11 +389,11 @@ def build_session_context_prompt(
|
||||
for platform, home in context.home_channels.items():
|
||||
hc_id = _hash_chat_id(home.chat_id) if redact_pii else home.chat_id
|
||||
lines.append(f" - {platform.value}: {home.name} (ID: {hc_id})")
|
||||
|
||||
|
||||
# Delivery options for scheduled tasks
|
||||
lines.append("")
|
||||
lines.append("**Delivery options for scheduled tasks:**")
|
||||
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
# Origin delivery
|
||||
@@ -398,15 +409,15 @@ def build_session_context_prompt(
|
||||
lines.append(
|
||||
f"- `\"local\"` → Save to local files only ({display_hermes_home()}/cron/output/)"
|
||||
)
|
||||
|
||||
|
||||
# Platform home channels
|
||||
for platform, home in context.home_channels.items():
|
||||
lines.append(f"- `\"{platform.value}\"` → Home channel ({home.name})")
|
||||
|
||||
|
||||
# Note about explicit targeting
|
||||
lines.append("")
|
||||
lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID.*")
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Top-level argparse construction for the hermes CLI.
|
||||
|
||||
Lives in its own module so other modules (e.g. ``relaunch.py``) can
|
||||
introspect the parser to discover which flags exist without running the
|
||||
``main`` fn.
|
||||
|
||||
Only the top-level parser and the ``chat`` subparser live here. Every other
|
||||
subparser (model, gateway, sessions, …) is built inline in ``main.py``
|
||||
because its dispatch is tightly coupled to module-level ``cmd_*`` functions.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before
|
||||
# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``),
|
||||
# so it isn't on the parser. Listed here so all "carry over on relaunch"
|
||||
# metadata lives in one file.
|
||||
PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [
|
||||
("--profile", True),
|
||||
("-p", True),
|
||||
]
|
||||
|
||||
|
||||
def _inherited_flag(parser, *args, **kwargs):
|
||||
"""Register a flag that ``hermes_cli.relaunch`` should carry over when
|
||||
the CLI re-execs itself (e.g. after ``sessions browse`` picks a session,
|
||||
or after the setup wizard launches chat).
|
||||
|
||||
Equivalent to ``parser.add_argument(...)`` plus tagging the resulting
|
||||
Action with ``inherit_on_relaunch = True`` so the relaunch table builder
|
||||
can find it via introspection.
|
||||
"""
|
||||
action = parser.add_argument(*args, **kwargs)
|
||||
action.inherit_on_relaunch = True
|
||||
return action
|
||||
|
||||
|
||||
_EPILOGUE = """
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
hermes gateway Run messaging gateway
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
"""
|
||||
|
||||
|
||||
def build_top_level_parser():
|
||||
"""Build the top-level parser, the subparsers action, and the ``chat`` subparser.
|
||||
|
||||
Returns ``(parser, subparsers, chat_parser)``. The caller wires
|
||||
``chat_parser.set_defaults(func=cmd_chat)`` and continues registering
|
||||
other subparsers via ``subparsers.add_parser(...)``.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=_EPILOGUE,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--toolsets",
|
||||
default=None,
|
||||
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# =========================================================================
|
||||
# chat command
|
||||
# =========================================================================
|
||||
chat_parser = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Interactive chat with the agent",
|
||||
description="Start an interactive chat session with Hermes Agent",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
return parser, subparsers, chat_parser
|
||||
+85
-37
@@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -122,35 +123,36 @@ def get_available_skills() -> Dict[str, List[str]]:
|
||||
# Cache update check results for 6 hours to avoid repeated git fetches
|
||||
_UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
|
||||
|
||||
# Sentinel returned when we know an update exists but can't count commits
|
||||
# (e.g. nix-built hermes — no local git history to count against).
|
||||
UPDATE_AVAILABLE_NO_COUNT = -1
|
||||
|
||||
def check_for_updates() -> Optional[int]:
|
||||
"""Check how many commits behind origin/main the local repo is.
|
||||
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||||
|
||||
Does a ``git fetch`` at most once every 6 hours (cached to
|
||||
``~/.hermes/.update_check``). Returns the number of commits behind,
|
||||
or ``None`` if the check fails or isn't applicable.
|
||||
|
||||
def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
"""Compare an embedded git revision to upstream main via ls-remote.
|
||||
|
||||
Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind,
|
||||
or ``None`` on failure.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
cache_file = hermes_home / ".update_check"
|
||||
|
||||
# Must be a git repo — fall back to project root for dev installs
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
# Read cache
|
||||
now = time.time()
|
||||
try:
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS:
|
||||
return cached.get("behind")
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
return None
|
||||
upstream_rev = result.stdout.split()[0]
|
||||
if not upstream_rev:
|
||||
return None
|
||||
return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT
|
||||
|
||||
# Fetch latest refs (fast — only downloads ref metadata, no files)
|
||||
|
||||
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
||||
"""Count commits behind origin/main in a local checkout."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "--quiet"],
|
||||
@@ -160,7 +162,6 @@ def check_for_updates() -> Optional[int]:
|
||||
except Exception:
|
||||
pass # Offline or timeout — use stale refs, that's fine
|
||||
|
||||
# Count commits behind
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD..origin/main"],
|
||||
@@ -168,15 +169,52 @@ def check_for_updates() -> Optional[int]:
|
||||
cwd=str(repo_dir),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
behind = int(result.stdout.strip())
|
||||
else:
|
||||
behind = None
|
||||
return int(result.stdout.strip())
|
||||
except Exception:
|
||||
behind = None
|
||||
pass
|
||||
return None
|
||||
|
||||
# Write cache
|
||||
|
||||
def check_for_updates() -> Optional[int]:
|
||||
"""Check whether a Hermes update is available.
|
||||
|
||||
Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare
|
||||
it to upstream main via ``git ls-remote``. Otherwise look for a local
|
||||
git checkout and count commits behind ``origin/main``.
|
||||
|
||||
Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1)
|
||||
if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if
|
||||
the check failed or doesn't apply. Cached for 6 hours.
|
||||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Read cache — invalidate if the embedded rev has changed since last check
|
||||
now = time.time()
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind}))
|
||||
if cache_file.exists():
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if (
|
||||
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
|
||||
and cached.get("rev") == embedded_rev
|
||||
):
|
||||
return cached.get("behind")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if embedded_rev:
|
||||
behind = _check_via_rev(embedded_rev)
|
||||
else:
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
||||
try:
|
||||
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -549,13 +587,23 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
# Update check — use prefetched result if available
|
||||
try:
|
||||
behind = get_update_result(timeout=0.5)
|
||||
if behind and behind > 0:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
if behind is not None and behind != 0:
|
||||
from hermes_cli.config import get_managed_update_command, recommended_update_command
|
||||
if behind > 0:
|
||||
commits_word = "commit" if behind == 1 else "commits"
|
||||
right_lines.append(
|
||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||
)
|
||||
else:
|
||||
# UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update
|
||||
# exists but not by how much, and we don't know how the user
|
||||
# installed it (nix run, profile, system flake, home-manager).
|
||||
managed_cmd = get_managed_update_command()
|
||||
line = "[bold yellow]⚠ update available[/]"
|
||||
if managed_cmd:
|
||||
line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]"
|
||||
right_lines.append(line)
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
cli_only=True),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("reload-skills", "Re-scan ~/.hermes/skills/ for newly installed or removed skills",
|
||||
"Tools & Skills", aliases=("reload_skills",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
cli_only=True, args_hint="[connect|disconnect|status]",
|
||||
subcommands=("connect", "disconnect", "status")),
|
||||
@@ -183,8 +185,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",
|
||||
cli_only=True, aliases=("exit",), args_hint="[--delete]"),
|
||||
CommandDef("quit", "Exit the CLI", "Exit",
|
||||
cli_only=True, aliases=("exit",)),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+221
-34
@@ -73,6 +73,8 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
|
||||
"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",
|
||||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
@@ -348,7 +350,7 @@ def ensure_hermes_home():
|
||||
else:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
_secure_dir(home)
|
||||
for subdir in ("cron", "sessions", "logs", "memories"):
|
||||
for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"):
|
||||
d = home / subdir
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
_secure_dir(d)
|
||||
@@ -369,6 +371,10 @@ def _ensure_hermes_home_managed(home: Path):
|
||||
f"{d} does not exist. "
|
||||
"Run 'sudo nixos-rebuild switch' first."
|
||||
)
|
||||
# Curator reports dir is a sub-path of logs/; create it if missing.
|
||||
# In managed mode the activation script may not know about this subdir,
|
||||
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
|
||||
(home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
|
||||
# Inside umask(0o007) scope — SOUL.md will be created as 0660
|
||||
_ensure_default_soul_md(home)
|
||||
|
||||
@@ -707,6 +713,19 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Curator — skill-usage review fork. Timeout is generous because the
|
||||
# review pass can take several minutes on reasoning models (umbrella
|
||||
# building over hundreds of candidate skills). "auto" = use main chat
|
||||
# model; override via `hermes model` → auxiliary → Curator to route
|
||||
# to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
|
||||
"curator": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 600,
|
||||
"extra_body": {},
|
||||
},
|
||||
},
|
||||
|
||||
"display": {
|
||||
@@ -764,7 +783,7 @@ DEFAULT_CONFIG = {
|
||||
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
|
||||
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
"tts": {
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "neutts" (local)
|
||||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
|
||||
"edge": {
|
||||
"voice": "en-US-AriaNeural",
|
||||
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
|
||||
@@ -794,6 +813,19 @@ DEFAULT_CONFIG = {
|
||||
"model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
|
||||
"device": "cpu", # cpu, cuda, or mps
|
||||
},
|
||||
"piper": {
|
||||
# Voice name (e.g. "en_US-lessac-medium") downloaded on first
|
||||
# use, OR an absolute path to a pre-downloaded .onnx file.
|
||||
# Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
|
||||
"voice": "en_US-lessac-medium",
|
||||
# "voices_dir": "", # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
|
||||
# "use_cuda": False, # Requires onnxruntime-gpu
|
||||
# "length_scale": 1.0, # 2.0 = twice as slow
|
||||
# "noise_scale": 0.667,
|
||||
# "noise_w_scale": 0.8,
|
||||
# "volume": 1.0,
|
||||
# "normalize_audio": True,
|
||||
},
|
||||
},
|
||||
|
||||
"stt": {
|
||||
@@ -947,12 +979,6 @@ DEFAULT_CONFIG = {
|
||||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
# Optional per-task override for the curator's aux model. Leave null
|
||||
# to use Hermes' main auxiliary client resolution.
|
||||
"auxiliary": {
|
||||
"provider": None,
|
||||
"model": None,
|
||||
},
|
||||
},
|
||||
|
||||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||||
@@ -1018,6 +1044,14 @@ DEFAULT_CONFIG = {
|
||||
"mode": "manual",
|
||||
"timeout": 60,
|
||||
"cron_mode": "deny",
|
||||
# When true, /reload-mcp asks the user to confirm before rebuilding
|
||||
# the MCP tool set for the active session. Reloading invalidates
|
||||
# the provider prompt cache (tool schemas are baked into the system
|
||||
# prompt), so the next message re-sends full input tokens — this can
|
||||
# be expensive on long-context or high-reasoning models. Users click
|
||||
# "Always Approve" to silence the prompt permanently; that flips
|
||||
# this key to false.
|
||||
"mcp_reload_confirm": True,
|
||||
},
|
||||
|
||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||
@@ -1171,7 +1205,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 22,
|
||||
"_config_version": 23,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -2076,6 +2110,43 @@ OPTIONAL_ENV_VARS = {
|
||||
"prompt": "QQ Sandbox Mode",
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_SERVER": {
|
||||
"description": "IRC server hostname (e.g. irc.libera.chat)",
|
||||
"prompt": "IRC server",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_CHANNEL": {
|
||||
"description": "IRC channel to join (e.g. #hermes)",
|
||||
"prompt": "IRC channel",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_NICKNAME": {
|
||||
"description": "Bot nickname on IRC (default: hermes-bot)",
|
||||
"prompt": "IRC nickname",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
},
|
||||
"IRC_SERVER_PASSWORD": {
|
||||
"description": "IRC server password (if required)",
|
||||
"prompt": "IRC server password",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"IRC_NICKSERV_PASSWORD": {
|
||||
"description": "NickServ password for nick identification",
|
||||
"prompt": "NickServ password",
|
||||
"url": None,
|
||||
"password": True,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"GATEWAY_ALLOW_ALL_USERS": {
|
||||
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
|
||||
"prompt": "Allow all users (true/false)",
|
||||
@@ -2238,19 +2309,55 @@ def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||||
return missing
|
||||
|
||||
|
||||
def _set_nested(config: dict, dotted_key: str, value):
|
||||
def _set_nested(config, dotted_key: str, value):
|
||||
"""Set a value at an arbitrarily nested dotted key path.
|
||||
|
||||
Creates intermediate dicts as needed, e.g. ``_set_nested(c, "a.b.c", 1)``
|
||||
ensures ``c["a"]["b"]["c"] == 1``.
|
||||
Supports both dict and list navigation:
|
||||
_set_nested(c, "a.b.c", 1) → c["a"]["b"]["c"] = 1
|
||||
_set_nested(c, "a.0.b", 1) → c["a"][0]["b"] = 1
|
||||
_set_nested(c, "providers.1", "x") → c["providers"][1] = "x"
|
||||
|
||||
Intermediate dicts are created on demand. List indices are parsed
|
||||
from numeric path segments; the referenced index must already exist
|
||||
(we do not grow lists — the user is navigating into structure they
|
||||
wrote themselves). If a segment targets a non-container leaf
|
||||
(scalar), the leaf is replaced with a fresh dict so the write can
|
||||
proceed — this preserves the pre-existing behavior for bare scalar
|
||||
overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a
|
||||
string).
|
||||
|
||||
Guards against #17876: before this fix the code unconditionally
|
||||
replaced any non-dict value (including lists) with ``{}``, silently
|
||||
destroying list-typed config like ``custom_providers`` whenever a
|
||||
caller used an indexed path.
|
||||
"""
|
||||
parts = dotted_key.split(".")
|
||||
current = config
|
||||
for part in parts[:-1]:
|
||||
if part not in current or not isinstance(current.get(part), dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
if isinstance(current, list):
|
||||
try:
|
||||
idx = int(part)
|
||||
except (TypeError, ValueError):
|
||||
raise TypeError(
|
||||
f"Cannot navigate into list at key {dotted_key!r}: "
|
||||
f"segment {part!r} is not a numeric index"
|
||||
)
|
||||
current = current[idx]
|
||||
elif isinstance(current, dict):
|
||||
existing = current.get(part)
|
||||
# Preserve dicts and lists; replace missing/scalar with a fresh dict.
|
||||
if part not in current or not isinstance(existing, (dict, list)):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
|
||||
)
|
||||
last = parts[-1]
|
||||
if isinstance(current, list):
|
||||
current[int(last)] = value
|
||||
else:
|
||||
current[last] = value
|
||||
|
||||
|
||||
def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||||
@@ -3171,6 +3278,90 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
"Use `hermes plugins enable <name>` to activate."
|
||||
)
|
||||
|
||||
# ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
|
||||
# The curator (background skill maintenance) was added in PR #16049, but
|
||||
# existing configs from before that PR (or before the April 2026
|
||||
# unification under `auxiliary.curator`) never wrote the curator section
|
||||
# to disk. The runtime deep-merge in `load_config()` fills defaults at
|
||||
# read time, so the curator *functions*; but users can't see/edit the
|
||||
# settings in their `config.yaml`, and `hermes curator status` has no
|
||||
# stable logs dir to point at until the first run mkdir's it.
|
||||
#
|
||||
# This migration:
|
||||
# 1. Writes the `curator` top-level section to config.yaml (enabled,
|
||||
# interval_hours, min_idle_hours, stale_after_days, archive_after_days)
|
||||
# — only keys the user hasn't already overridden.
|
||||
# 2. Writes the `auxiliary.curator` aux-task slot (provider, model,
|
||||
# base_url, api_key, timeout, extra_body) — canonical slot for
|
||||
# routing the curator fork to a cheaper aux model.
|
||||
# 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
|
||||
# on top of ensure_hermes_home() — old profiles that predate this
|
||||
# migration still benefit).
|
||||
if current_ver < 23:
|
||||
try:
|
||||
curator_dir = get_hermes_home() / "logs" / "curator"
|
||||
curator_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
results["warnings"].append(f"Could not create {curator_dir}: {e}")
|
||||
|
||||
config = read_raw_config()
|
||||
touched = False
|
||||
|
||||
# (1) Top-level curator section — only add missing keys
|
||||
_curator_defaults = DEFAULT_CONFIG.get("curator", {})
|
||||
raw_curator = config.get("curator")
|
||||
if not isinstance(raw_curator, dict):
|
||||
raw_curator = {}
|
||||
added_curator: List[str] = []
|
||||
for k, v in _curator_defaults.items():
|
||||
if k not in raw_curator:
|
||||
raw_curator[k] = copy.deepcopy(v)
|
||||
added_curator.append(k)
|
||||
if added_curator:
|
||||
config["curator"] = raw_curator
|
||||
touched = True
|
||||
|
||||
# (2) auxiliary.curator task slot
|
||||
_aux_curator_defaults = (
|
||||
DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
|
||||
)
|
||||
raw_aux = config.get("auxiliary")
|
||||
if not isinstance(raw_aux, dict):
|
||||
raw_aux = {}
|
||||
raw_aux_curator = raw_aux.get("curator")
|
||||
if not isinstance(raw_aux_curator, dict):
|
||||
raw_aux_curator = {}
|
||||
added_aux: List[str] = []
|
||||
for k, v in _aux_curator_defaults.items():
|
||||
if k not in raw_aux_curator:
|
||||
raw_aux_curator[k] = copy.deepcopy(v)
|
||||
added_aux.append(k)
|
||||
if added_aux:
|
||||
raw_aux["curator"] = raw_aux_curator
|
||||
config["auxiliary"] = raw_aux
|
||||
touched = True
|
||||
|
||||
if touched:
|
||||
save_config(config)
|
||||
if added_curator:
|
||||
results["config_added"].append(
|
||||
f"curator ({len(added_curator)} default key(s))"
|
||||
)
|
||||
if not quiet:
|
||||
print(
|
||||
" ✓ Seeded curator defaults in config.yaml: "
|
||||
f"{', '.join(added_curator)}"
|
||||
)
|
||||
if added_aux:
|
||||
results["config_added"].append(
|
||||
f"auxiliary.curator ({len(added_aux)} default key(s))"
|
||||
)
|
||||
if not quiet:
|
||||
print(
|
||||
" ✓ Seeded auxiliary.curator defaults in config.yaml: "
|
||||
f"{', '.join(added_aux)}"
|
||||
)
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
@@ -3443,17 +3634,17 @@ def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
|
||||
|
||||
|
||||
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Move stale root-level provider/base_url into model section.
|
||||
"""Move stale root-level provider/base_url/context_length into model section.
|
||||
|
||||
Some users (or older code) placed ``provider:`` and ``base_url:`` at the
|
||||
config root instead of inside ``model:``. These root-level keys are only
|
||||
used as a fallback when the corresponding ``model.*`` key is empty — they
|
||||
never override an existing ``model.provider`` or ``model.base_url``.
|
||||
Some users (or older code) placed ``provider:``, ``base_url:``, or
|
||||
``context_length:`` at the config root instead of inside ``model:``.
|
||||
These root-level keys are only used as a fallback when the corresponding
|
||||
``model.*`` key is empty — they never override an existing value.
|
||||
After migration the root-level keys are removed so they can't cause
|
||||
confusion on subsequent loads.
|
||||
"""
|
||||
# Only act if there are root-level keys to migrate
|
||||
has_root = any(config.get(k) for k in ("provider", "base_url"))
|
||||
has_root = any(config.get(k) for k in ("provider", "base_url", "context_length"))
|
||||
if not has_root:
|
||||
return config
|
||||
|
||||
@@ -3463,7 +3654,7 @@ def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model = {"default": model} if model else {}
|
||||
config["model"] = model
|
||||
|
||||
for key in ("provider", "base_url"):
|
||||
for key in ("provider", "base_url", "context_length"):
|
||||
root_val = config.get(key)
|
||||
if root_val and not model.get(key):
|
||||
model[key] = root_val
|
||||
@@ -4354,15 +4545,11 @@ def set_config_value(key: str, value: str):
|
||||
except Exception:
|
||||
user_config = {}
|
||||
|
||||
# Handle nested keys (e.g., "tts.provider")
|
||||
parts = key.split('.')
|
||||
current = user_config
|
||||
|
||||
for part in parts[:-1]:
|
||||
if part not in current or not isinstance(current.get(part), dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Handle nested keys (e.g., "tts.provider") including numeric list
|
||||
# indices (e.g., "custom_providers.0.api_key"). Delegates to
|
||||
# _set_nested which preserves list-typed nodes; before #17876 the
|
||||
# inline navigation here silently overwrote lists with dicts.
|
||||
|
||||
# Convert value to appropriate type
|
||||
if value.lower() in ('true', 'yes', 'on'):
|
||||
value = True
|
||||
@@ -4372,8 +4559,8 @@ def set_config_value(key: str, value: str):
|
||||
value = int(value)
|
||||
elif value.replace('.', '', 1).isdigit():
|
||||
value = float(value)
|
||||
|
||||
current[parts[-1]] = value
|
||||
|
||||
_set_nested(user_config, key, value)
|
||||
|
||||
# Write only user config back (not the full merged defaults)
|
||||
ensure_hermes_home()
|
||||
|
||||
+14
-5
@@ -88,16 +88,25 @@ def _cmd_status(args) -> int:
|
||||
if pinned:
|
||||
print(f"\npinned ({len(pinned)}): {', '.join(pinned)}")
|
||||
|
||||
# Show top 5 least-recently-used active skills
|
||||
# Show top 5 least-recently-active skills. Views and edits are activity too:
|
||||
# curator should not report a skill as "never used" right after skill_view()
|
||||
# or skill_manage() touched it.
|
||||
active = sorted(
|
||||
by_state.get("active", []),
|
||||
key=lambda r: r.get("last_used_at") or r.get("created_at") or "",
|
||||
key=lambda r: r.get("last_activity_at") or r.get("created_at") or "",
|
||||
)[:5]
|
||||
if active:
|
||||
print("\nleast recently used (top 5):")
|
||||
print("\nleast recently active (top 5):")
|
||||
for r in active:
|
||||
last = _fmt_ts(r.get("last_used_at"))
|
||||
print(f" {r['name']:40s} use={r.get('use_count', 0):3d} last_used={last}")
|
||||
last = _fmt_ts(r.get("last_activity_at"))
|
||||
print(
|
||||
f" {r['name']:40s} "
|
||||
f"activity={r.get('activity_count', 0):3d} "
|
||||
f"use={r.get('use_count', 0):3d} "
|
||||
f"view={r.get('view_count', 0):3d} "
|
||||
f"patches={r.get('patch_count', 0):3d} "
|
||||
f"last_activity={last}"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
+42
-9
@@ -78,6 +78,14 @@ def _system_package_install_cmd(pkg: str) -> str:
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _safe_which(cmd: str) -> str | None:
|
||||
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
|
||||
try:
|
||||
return shutil.which(cmd)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
@@ -579,7 +587,7 @@ def run_doctor(args):
|
||||
except Exception as e:
|
||||
check_warn("Auth provider status", f"(could not check: {e})")
|
||||
|
||||
if shutil.which("codex"):
|
||||
if _safe_which("codex"):
|
||||
check_ok("codex CLI")
|
||||
else:
|
||||
# Native OAuth uses Hermes' own device-code flow — the Codex CLI is
|
||||
@@ -797,13 +805,13 @@ def run_doctor(args):
|
||||
print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
# Git
|
||||
if shutil.which("git"):
|
||||
if _safe_which("git"):
|
||||
check_ok("git")
|
||||
else:
|
||||
check_warn("git not found", "(optional)")
|
||||
|
||||
# ripgrep (optional, for faster file search)
|
||||
if shutil.which("rg"):
|
||||
if _safe_which("rg"):
|
||||
check_ok("ripgrep (rg)", "(faster file search)")
|
||||
else:
|
||||
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
|
||||
@@ -812,7 +820,7 @@ def run_doctor(args):
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
if _safe_which("docker"):
|
||||
# Check if docker daemon is running
|
||||
try:
|
||||
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
|
||||
@@ -827,7 +835,7 @@ def run_doctor(args):
|
||||
check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
|
||||
issues.append("Install Docker or change TERMINAL_ENV")
|
||||
else:
|
||||
if shutil.which("docker"):
|
||||
if _safe_which("docker"):
|
||||
check_ok("docker", "(optional)")
|
||||
else:
|
||||
if _is_termux():
|
||||
@@ -918,7 +926,7 @@ def run_doctor(args):
|
||||
check_info("Vercel persistence: ephemeral filesystem")
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if shutil.which("node"):
|
||||
if _safe_which("node"):
|
||||
check_ok("Node.js")
|
||||
# Check if agent-browser is installed
|
||||
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
@@ -944,7 +952,7 @@ def run_doctor(args):
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
if _safe_which("npm"):
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
|
||||
@@ -1023,10 +1031,16 @@ def run_doctor(args):
|
||||
print(" Checking Anthropic API...", end="", flush=True)
|
||||
try:
|
||||
import httpx
|
||||
from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_COMMON_BETAS,
|
||||
_OAUTH_ONLY_BETAS,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(anthropic_key):
|
||||
is_oauth = _is_oauth_token(anthropic_key)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {anthropic_key}"
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
@@ -1036,6 +1050,25 @@ def run_doctor(args):
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
# Reactive recovery: OAuth subscriptions that don't include 1M
|
||||
# context reject the request with 400 "long context beta is not
|
||||
# yet available for this subscription". Retry once with that
|
||||
# beta stripped so the doctor check doesn't falsely report the
|
||||
# Anthropic API as unreachable for those users.
|
||||
if (
|
||||
is_oauth
|
||||
and response.status_code == 400
|
||||
and "long context beta" in response.text.lower()
|
||||
and "not yet available" in response.text.lower()
|
||||
):
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
response = httpx.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f"\r {color('✓', Colors.GREEN)} Anthropic API ")
|
||||
elif response.status_code == 401:
|
||||
|
||||
+157
-32
@@ -2368,7 +2368,11 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
# Exit with code 1 if gateway fails to connect any platform,
|
||||
# so systemd Restart=on-failure will retry on transient errors
|
||||
verbosity = None if quiet else verbose
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
try:
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
except KeyboardInterrupt:
|
||||
print("\nGateway stopped.")
|
||||
return
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
@@ -2761,15 +2765,77 @@ _PLATFORMS = [
|
||||
],
|
||||
},
|
||||
]
|
||||
def _all_platforms() -> list[dict]:
|
||||
"""Return the full list of platforms for setup menus.
|
||||
|
||||
Combines the built-in ``_PLATFORMS`` with plugin platforms registered via
|
||||
``platform_registry``. Plugins are discovered on first call so bundled
|
||||
platforms (like IRC, which auto-load via ``kind: platform``) appear in
|
||||
``hermes setup gateway`` without needing the gateway to be running.
|
||||
Built-ins keep their dict shape; plugin entries are adapted to the same
|
||||
shape with ``_registry_entry`` holding the source.
|
||||
"""
|
||||
# Populate the registry so plugin platforms are visible. Idempotent.
|
||||
# Bundled platform plugins (``kind: platform``) auto-load unconditionally,
|
||||
# so every shipped messaging channel appears in the setup menu by default.
|
||||
# User-installed platform plugins under ~/.hermes/plugins/ still require
|
||||
# opt-in via ``plugins.enabled`` (untrusted code).
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception as e:
|
||||
logger.debug("plugin discovery failed during platform enumeration: %s", e)
|
||||
|
||||
platforms = [dict(p) for p in _PLATFORMS]
|
||||
by_key = {p["key"]: p for p in platforms}
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
except Exception:
|
||||
return platforms
|
||||
|
||||
for entry in platform_registry.all_entries():
|
||||
if entry.name in by_key:
|
||||
continue # built-in already covers it
|
||||
platforms.append({
|
||||
"key": entry.name,
|
||||
"label": entry.label,
|
||||
"emoji": entry.emoji,
|
||||
"token_var": entry.required_env[0] if entry.required_env else "",
|
||||
"install_hint": entry.install_hint,
|
||||
"_registry_entry": entry,
|
||||
})
|
||||
return platforms
|
||||
|
||||
|
||||
def _platform_status(platform: dict) -> str:
|
||||
"""Return a plain-text status string for a platform.
|
||||
|
||||
Returns uncolored text so it can safely be embedded in
|
||||
simple_term_menu items (ANSI codes break width calculation).
|
||||
curses menu items (ANSI codes break width calculation).
|
||||
"""
|
||||
token_var = platform["token_var"]
|
||||
entry = platform.get("_registry_entry")
|
||||
if entry is not None:
|
||||
configured = False
|
||||
# Prefer is_connected (checks both env and config.yaml) over
|
||||
# check_fn (typically just dependency / env presence).
|
||||
if entry.is_connected is not None:
|
||||
try:
|
||||
from gateway.config import PlatformConfig
|
||||
synthetic = PlatformConfig(enabled=True)
|
||||
configured = bool(entry.is_connected(synthetic))
|
||||
except Exception:
|
||||
configured = False
|
||||
if not configured:
|
||||
try:
|
||||
configured = bool(entry.check_fn())
|
||||
except Exception:
|
||||
configured = False
|
||||
return "configured" if configured else "not configured"
|
||||
|
||||
token_var = platform.get("token_var", "")
|
||||
if not token_var:
|
||||
return "not configured"
|
||||
val = get_env_value(token_var)
|
||||
if token_var == "WHATSAPP_ENABLED":
|
||||
if val and val.lower() == "true":
|
||||
@@ -3727,6 +3793,71 @@ def _setup_signal():
|
||||
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
|
||||
|
||||
|
||||
def _builtin_setup_fn(key: str):
|
||||
"""Resolve the interactive setup function for a built-in platform key.
|
||||
|
||||
Late-bound to avoid a circular import with ``hermes_cli.setup`` (which
|
||||
imports from this module for the remaining bespoke flows).
|
||||
"""
|
||||
from hermes_cli import setup as _s
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
"discord": _s._setup_discord,
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
"mattermost": _s._setup_mattermost,
|
||||
"bluebubbles": _s._setup_bluebubbles,
|
||||
"webhooks": _s._setup_webhooks,
|
||||
"signal": _setup_signal,
|
||||
"whatsapp": _setup_whatsapp,
|
||||
"weixin": _setup_weixin,
|
||||
"dingtalk": _setup_dingtalk,
|
||||
"feishu": _setup_feishu,
|
||||
"wecom": _setup_wecom,
|
||||
"qqbot": _setup_qqbot,
|
||||
}.get(key)
|
||||
def _configure_platform(platform: dict) -> None:
|
||||
"""Run the interactive setup flow for a single platform.
|
||||
|
||||
Dispatch order:
|
||||
1. Plugin-provided ``setup_fn`` on the registry entry.
|
||||
2. Built-in setup function matched by platform key.
|
||||
3. ``_setup_standard_platform`` when the entry has a ``vars`` schema.
|
||||
4. Env-var hint fallback for plugins that offer no setup helper.
|
||||
|
||||
Bundled platform plugins (e.g. IRC) auto-load, so no plugin enable step
|
||||
is needed here. User-installed platform plugins under ~/.hermes/plugins/
|
||||
must already be in ``plugins.enabled`` before they appear in this menu.
|
||||
"""
|
||||
entry = platform.get("_registry_entry")
|
||||
|
||||
if entry is not None and entry.setup_fn is not None:
|
||||
entry.setup_fn()
|
||||
return
|
||||
|
||||
fn = _builtin_setup_fn(platform["key"])
|
||||
if fn is not None:
|
||||
fn()
|
||||
return
|
||||
|
||||
if platform.get("vars"):
|
||||
_setup_standard_platform(platform)
|
||||
return
|
||||
|
||||
# Plugin with no setup helper — show env-var instructions.
|
||||
label = platform.get("label", platform["key"])
|
||||
emoji = platform.get("emoji", "🔌")
|
||||
print()
|
||||
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
|
||||
required = entry.required_env if entry else []
|
||||
if required:
|
||||
print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}")
|
||||
else:
|
||||
print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}")
|
||||
if platform.get("install_hint"):
|
||||
print_info(f" {platform['install_hint']}")
|
||||
|
||||
|
||||
def gateway_setup():
|
||||
"""Interactive setup for messaging platforms + gateway service."""
|
||||
if is_managed():
|
||||
@@ -3779,42 +3910,36 @@ def gateway_setup():
|
||||
print()
|
||||
print_header("Messaging Platforms")
|
||||
|
||||
menu_items = []
|
||||
for plat in _PLATFORMS:
|
||||
status = _platform_status(plat)
|
||||
menu_items.append(f"{plat['label']} ({status})")
|
||||
platforms = _all_platforms()
|
||||
|
||||
menu_items = [
|
||||
f"{p['emoji']} {p['label']} ({_platform_status(p)})"
|
||||
for p in platforms
|
||||
]
|
||||
menu_items.append("Done")
|
||||
|
||||
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
|
||||
|
||||
if choice == len(_PLATFORMS):
|
||||
if choice == len(platforms):
|
||||
break
|
||||
|
||||
platform = _PLATFORMS[choice]
|
||||
|
||||
if platform["key"] == "whatsapp":
|
||||
_setup_whatsapp()
|
||||
elif platform["key"] == "signal":
|
||||
_setup_signal()
|
||||
elif platform["key"] == "weixin":
|
||||
_setup_weixin()
|
||||
elif platform["key"] == "dingtalk":
|
||||
_setup_dingtalk()
|
||||
elif platform["key"] == "feishu":
|
||||
_setup_feishu()
|
||||
elif platform["key"] == "qqbot":
|
||||
_setup_qqbot()
|
||||
elif platform["key"] == "wecom":
|
||||
_setup_wecom()
|
||||
else:
|
||||
_setup_standard_platform(platform)
|
||||
_configure_platform(platforms[choice])
|
||||
|
||||
# ── Post-setup: offer to install/restart gateway ──
|
||||
# Consider any platform (built-in or plugin) where the user has made
|
||||
# meaningful progress. ``_platform_status`` already handles plugin
|
||||
# entries via their check_fn and per-platform dual-states like
|
||||
# WhatsApp's "enabled, not paired".
|
||||
def _is_progress(status: str) -> bool:
|
||||
s = status.lower()
|
||||
return not (
|
||||
s == "not configured"
|
||||
or s.startswith("partially")
|
||||
or s.startswith("plugin disabled")
|
||||
)
|
||||
|
||||
any_configured = any(
|
||||
bool(get_env_value(p["token_var"]))
|
||||
for p in _PLATFORMS
|
||||
if p["key"] != "whatsapp"
|
||||
) or (get_env_value("WHATSAPP_ENABLED") or "").lower() == "true"
|
||||
_is_progress(_platform_status(p)) for p in _all_platforms()
|
||||
)
|
||||
|
||||
if any_configured:
|
||||
print()
|
||||
@@ -4252,4 +4377,4 @@ def _gateway_command_inner(args):
|
||||
if not supports_systemd_services() and not is_macos():
|
||||
print("Legacy unit migration only applies to systemd-based Linux hosts.")
|
||||
return
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
|
||||
+214
-332
@@ -114,6 +114,12 @@ def _apply_profile_override() -> None:
|
||||
consume = 1
|
||||
break
|
||||
|
||||
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
|
||||
# This lets child processes (relaunch, subprocess) inherit the parent's
|
||||
# profile choice without having to pass --profile again.
|
||||
if profile_name is None and os.environ.get("HERMES_HOME"):
|
||||
return
|
||||
|
||||
# 2. If no flag, check active_profile in the hermes root
|
||||
if profile_name is None:
|
||||
try:
|
||||
@@ -830,6 +836,16 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
|
||||
|
||||
|
||||
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"})
|
||||
_TUI_PREBUILT_MARKER = ".hermes-prebuilt-tui"
|
||||
|
||||
|
||||
def _tui_prebuilt_ready(root: Path) -> bool:
|
||||
return (
|
||||
(root / _TUI_PREBUILT_MARKER).is_file()
|
||||
and (root / "dist" / "entry.js").is_file()
|
||||
and (root / "node_modules" / "@hermes" / "ink" / "package.json").is_file()
|
||||
and (root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js").is_file()
|
||||
)
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
@@ -852,6 +868,9 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
we'd rather not force a reinstall for them. Falls back to mtime
|
||||
comparison if either lockfile is unparseable.
|
||||
"""
|
||||
if _tui_prebuilt_ready(root):
|
||||
return False
|
||||
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
if not ink.is_file():
|
||||
return True
|
||||
@@ -905,6 +924,8 @@ def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:
|
||||
|
||||
|
||||
def _tui_build_needed(tui_dir: Path) -> bool:
|
||||
if _tui_prebuilt_ready(tui_dir):
|
||||
return False
|
||||
if _hermes_ink_bundle_stale(tui_dir):
|
||||
return True
|
||||
entry = tui_dir / "dist" / "entry.js"
|
||||
@@ -1921,6 +1942,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
|
||||
("mcp", "MCP", "MCP tool reasoning"),
|
||||
("title_generation", "Title generation", "session titles"),
|
||||
("skills_hub", "Skills hub", "skills search/install"),
|
||||
("curator", "Curator", "skill-usage review pass"),
|
||||
]
|
||||
|
||||
|
||||
@@ -5329,8 +5351,8 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _warn_stale_dashboard_processes() -> None:
|
||||
"""Warn about running dashboard processes that still hold pre-update code.
|
||||
def _find_stale_dashboard_pids() -> list[int]:
|
||||
"""Return PIDs of ``hermes dashboard`` processes other than ourselves.
|
||||
|
||||
``hermes dashboard`` is a long-lived server process commonly started and
|
||||
forgotten. When ``hermes update`` replaces files on disk, the running
|
||||
@@ -5338,9 +5360,13 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
disk is updated, causing a silent frontend/backend mismatch (e.g. new
|
||||
auth headers the old backend doesn't recognise → every API call 401s).
|
||||
|
||||
Unlike the gateway, the dashboard has no service manager (systemd /
|
||||
launchd), so we can only warn — we don't auto-kill user-managed
|
||||
background processes.
|
||||
The dashboard has no service manager (systemd / launchd), no PID file,
|
||||
and we can't know the original launch args — so the only sane action
|
||||
after an update is to kill the stale process and let the user restart
|
||||
it. This helper is just the detection step; see
|
||||
``_kill_stale_dashboard_processes`` for the kill.
|
||||
|
||||
Returns an empty list on any scan error (missing ps/wmic, timeout, etc.).
|
||||
"""
|
||||
patterns = [
|
||||
"hermes dashboard",
|
||||
@@ -5366,7 +5392,7 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
encoding="utf-8", errors="ignore",
|
||||
)
|
||||
if result.returncode != 0 or result.stdout is None:
|
||||
return
|
||||
return []
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split("\n"):
|
||||
line = line.strip()
|
||||
@@ -5392,7 +5418,7 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split("\n"):
|
||||
for line in getattr(result, "stdout", "").split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped or "grep" in stripped:
|
||||
continue
|
||||
@@ -5408,20 +5434,112 @@ def _warn_stale_dashboard_processes() -> None:
|
||||
and pid != self_pid):
|
||||
dashboard_pids.append(pid)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return
|
||||
return []
|
||||
|
||||
if not dashboard_pids:
|
||||
return dashboard_pids
|
||||
|
||||
|
||||
def _kill_stale_dashboard_processes(
|
||||
reason: str = "the running backend no longer matches the updated frontend",
|
||||
) -> None:
|
||||
"""Kill running ``hermes dashboard`` processes.
|
||||
|
||||
Called at the end of ``hermes update`` (default ``reason``) and also
|
||||
from ``hermes dashboard --stop`` (which overrides ``reason``). The
|
||||
dashboard has no service manager, so after a code update the running
|
||||
process is guaranteed to be serving stale Python against a
|
||||
freshly-updated JS bundle. Leaving it alive produces silent
|
||||
frontend/backend mismatches (new auth headers the old backend doesn't
|
||||
recognise → every API call 401s).
|
||||
|
||||
POSIX: SIGTERM, wait up to ~3s for graceful exit, SIGKILL any survivors.
|
||||
Windows: ``taskkill /PID <pid> /F`` since there's no clean SIGTERM
|
||||
equivalent for background console apps.
|
||||
|
||||
The dashboard isn't auto-restarted because we don't know the original
|
||||
launch args (--host, --port, --insecure, --tui, --no-open). The user
|
||||
restarts it manually; a hint is printed.
|
||||
"""
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
return
|
||||
|
||||
print()
|
||||
print(f"⚠ {len(dashboard_pids)} dashboard process(es) still running "
|
||||
f"with the previous version:")
|
||||
for pid in dashboard_pids:
|
||||
print(f" PID {pid}")
|
||||
print(" The running backend may not match the updated frontend,")
|
||||
print(" causing silent auth failures or empty data.")
|
||||
print(" Restart them to pick up the changes:")
|
||||
print(" kill <pid> && hermes dashboard --port <port> ...")
|
||||
print(f"⟲ Stopping {len(pids)} dashboard process(es) ({reason})")
|
||||
|
||||
killed: list[int] = []
|
||||
failed: list[tuple[int, str]] = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
for pid in pids:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/F"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
killed.append(pid)
|
||||
else:
|
||||
failed.append((pid, (result.stderr or result.stdout or "").strip()))
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
else:
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
|
||||
# SIGTERM first — give each process a chance to shut down cleanly
|
||||
# (uvicorn closes its socket, flushes logs, etc.).
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
# Already gone — count as killed.
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
|
||||
# Poll for exit up to ~3s total.
|
||||
deadline = _time.monotonic() + 3.0
|
||||
pending = [p for p in pids if p not in killed
|
||||
and p not in {f[0] for f in failed}]
|
||||
while pending and _time.monotonic() < deadline:
|
||||
_time.sleep(0.1)
|
||||
still_pending = []
|
||||
for pid in pending:
|
||||
try:
|
||||
os.kill(pid, 0) # probe
|
||||
except ProcessLookupError:
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError):
|
||||
# Can't probe — assume still there.
|
||||
still_pending.append(pid)
|
||||
else:
|
||||
still_pending.append(pid)
|
||||
pending = still_pending
|
||||
|
||||
# SIGKILL any survivors.
|
||||
for pid in pending:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
killed.append(pid)
|
||||
except ProcessLookupError:
|
||||
killed.append(pid)
|
||||
except (PermissionError, OSError) as e:
|
||||
failed.append((pid, str(e)))
|
||||
|
||||
for pid in killed:
|
||||
print(f" ✓ stopped PID {pid}")
|
||||
for pid, reason in failed:
|
||||
print(f" ✗ failed to stop PID {pid}: {reason}")
|
||||
|
||||
if killed:
|
||||
print(" Restart the dashboard when you're ready:")
|
||||
print(" hermes dashboard --port <port>")
|
||||
|
||||
|
||||
# Back-compat alias: some tests and any external callers may import the old
|
||||
# warn-only name. The new behaviour (kill stale processes) replaces it.
|
||||
_warn_stale_dashboard_processes = _kill_stale_dashboard_processes
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
@@ -5558,7 +5676,7 @@ def _update_via_zip(args):
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
_warn_stale_dashboard_processes()
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
|
||||
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
|
||||
@@ -7375,9 +7493,12 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("Legacy unit check during update failed: %s", e)
|
||||
|
||||
# Warn about stale dashboard processes — the dashboard has no
|
||||
# service manager, so we can only tell the user to restart them.
|
||||
_warn_stale_dashboard_processes()
|
||||
# Kill stale dashboard processes — the dashboard has no service
|
||||
# manager, so leaving it alive after a code update produces a
|
||||
# silent frontend/backend mismatch. We can't auto-restart it
|
||||
# (no saved launch args) but we can stop it, and a hint is
|
||||
# printed for the user to re-launch.
|
||||
_kill_stale_dashboard_processes()
|
||||
|
||||
print()
|
||||
print("Tip: You can now select a provider and model:")
|
||||
@@ -7768,8 +7889,59 @@ def cmd_profile(args):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _report_dashboard_status() -> int:
|
||||
"""Print ``hermes dashboard`` PIDs and return the count.
|
||||
|
||||
Uses the same detection logic as ``_find_stale_dashboard_pids`` (the
|
||||
current process is excluded, but since ``hermes dashboard --status``
|
||||
runs in a short-lived CLI process that never matches the pattern,
|
||||
the exclusion is irrelevant here).
|
||||
"""
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
print("No hermes dashboard processes running.")
|
||||
return 0
|
||||
|
||||
print(f"{len(pids)} hermes dashboard process(es) running:")
|
||||
for pid in pids:
|
||||
# Best-effort: show the full cmdline so users can tell profiles apart.
|
||||
cmdline = ""
|
||||
try:
|
||||
if sys.platform != "win32":
|
||||
cmdline_path = f"/proc/{pid}/cmdline"
|
||||
if os.path.exists(cmdline_path):
|
||||
with open(cmdline_path, "rb") as f:
|
||||
cmdline = f.read().replace(b"\x00", b" ").decode(
|
||||
"utf-8", errors="replace").strip()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if cmdline:
|
||||
print(f" PID {pid}: {cmdline}")
|
||||
else:
|
||||
print(f" PID {pid}")
|
||||
return len(pids)
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server."""
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
if getattr(args, "status", False):
|
||||
count = _report_dashboard_status()
|
||||
sys.exit(0 if count == 0 else 0) # status is informational, always 0
|
||||
|
||||
# --stop: kill any running dashboards and exit, no deps needed.
|
||||
if getattr(args, "stop", False):
|
||||
pids = _find_stale_dashboard_pids()
|
||||
if not pids:
|
||||
print("No hermes dashboard processes running.")
|
||||
sys.exit(0)
|
||||
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`.
|
||||
_kill_stale_dashboard_processes(reason="requested via --stop")
|
||||
# _kill_stale_dashboard_processes prints outcomes itself. Exit 0 if
|
||||
# we killed at least one, 1 if they were all unkillable.
|
||||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
import uvicorn # noqa: F401
|
||||
@@ -7836,308 +8008,9 @@ def cmd_logs(args):
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes",
|
||||
description="Hermes Agent - AI assistant with tool-calling capabilities",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes fallback [list] Show fallback provider chain
|
||||
hermes fallback add Add a fallback provider (same picker as `hermes model`)
|
||||
hermes fallback remove Remove a fallback provider from the chain
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
hermes config set model gpt-4 Set a config value
|
||||
hermes gateway Run messaging gateway
|
||||
hermes -s hermes-agent-dev,github-auth
|
||||
hermes -w Start in isolated git worktree
|
||||
hermes gateway install Install gateway background service
|
||||
hermes sessions list List past sessions
|
||||
hermes sessions browse Interactive session picker
|
||||
hermes sessions rename ID T Rename/title a session
|
||||
hermes logs View agent.log (last 50 lines)
|
||||
hermes logs -f Follow agent.log in real time
|
||||
hermes logs errors View errors.log
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version", "-V", action="store_true", help="Show version and exit"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-z",
|
||||
"--oneshot",
|
||||
metavar="PROMPT",
|
||||
default=None,
|
||||
help=(
|
||||
"One-shot mode: send a single prompt and print ONLY the final "
|
||||
"response text to stdout. No banner, no spinner, no tool "
|
||||
"previews, no session_id line. Tools, memory, rules, and "
|
||||
"AGENTS.md in the CWD are loaded as normal; approvals are "
|
||||
"auto-bypassed. Intended for scripts / pipes."
|
||||
),
|
||||
)
|
||||
# --model / --provider are accepted at the top level so they can pair
|
||||
# with -z without needing the `chat` subcommand. If neither -z nor a
|
||||
# subcommand consumes them, they fall through harmlessly as None.
|
||||
# Mirrors `hermes chat --model ... --provider ...` semantics.
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--model",
|
||||
default=None,
|
||||
help=(
|
||||
"Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
default=None,
|
||||
help=(
|
||||
"Provider override for this invocation (e.g. openrouter, anthropic). "
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--toolsets",
|
||||
default=None,
|
||||
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills",
|
||||
"-s",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# =========================================================================
|
||||
# chat command
|
||||
# =========================================================================
|
||||
chat_parser = subparsers.add_parser(
|
||||
"chat",
|
||||
help="Interactive chat with the agent",
|
||||
description="Start an interactive chat session with Hermes Agent",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-q", "--query", help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image", help="Optional local image path to attach to a single query"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-t", "--toolsets", help="Comma-separated toolsets to enable"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-s",
|
||||
"--skills",
|
||||
action="append",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
# No `choices=` here: user-defined providers from config.yaml `providers:`
|
||||
# are also valid values, and runtime resolution (resolve_runtime_provider)
|
||||
# handles validation/error reporting consistently with the top-level
|
||||
# `--provider` flag.
|
||||
default=None,
|
||||
help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Verbose output"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-Q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree",
|
||||
"-w",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--max-turns",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--dev",
|
||||
dest="tui_dev",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="With --tui: run TypeScript sources via tsx (skip dist build)",
|
||||
)
|
||||
parser, subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
# =========================================================================
|
||||
@@ -9807,15 +9680,8 @@ Examples:
|
||||
|
||||
# Launch hermes --resume <id> by replacing the current process
|
||||
print(f"Resuming session: {selected_id}")
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
|
||||
else:
|
||||
# Fallback: re-invoke via python -m
|
||||
os.execvp(
|
||||
sys.executable,
|
||||
[sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
|
||||
)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["--resume", selected_id])
|
||||
return # won't reach here after execvp
|
||||
|
||||
elif action == "stats":
|
||||
@@ -10173,6 +10039,22 @@ Examples:
|
||||
"Alternatively set HERMES_DASHBOARD_TUI=1."
|
||||
),
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
# no service manager and no PID file, so these scan the process table
|
||||
# for `hermes dashboard` cmdlines and SIGTERM them directly — the same
|
||||
# path `hermes update` uses to clean up stale dashboards.
|
||||
dashboard_parser.add_argument(
|
||||
"--stop",
|
||||
action="store_true",
|
||||
help="Stop all running hermes dashboard processes and exit",
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="List running hermes dashboard processes and exit",
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -539,6 +539,7 @@ def resolve_display_context_length(
|
||||
api_key: str = "",
|
||||
model_info: Optional[ModelInfo] = None,
|
||||
custom_providers: list | None = None,
|
||||
config_context_length: int | None = None,
|
||||
) -> Optional[int]:
|
||||
"""Resolve the context length to show in /model output.
|
||||
|
||||
@@ -565,6 +566,7 @@ def resolve_display_context_length(
|
||||
api_key=api_key or "",
|
||||
provider=provider or None,
|
||||
custom_providers=custom_providers,
|
||||
config_context_length=config_context_length,
|
||||
)
|
||||
if ctx:
|
||||
return int(ctx)
|
||||
@@ -1501,7 +1503,14 @@ def list_authenticated_providers(
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
slug = current_provider or custom_provider_slug(display_name)
|
||||
# Guard against bare "custom" slug left by a prior
|
||||
# failed switch — always resolve to the canonical
|
||||
# custom:<name> form. (GH #17478)
|
||||
slug = (
|
||||
current_provider
|
||||
if current_provider and current_provider != "custom"
|
||||
else custom_provider_slug(display_name)
|
||||
)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
|
||||
+44
-16
@@ -2034,28 +2034,56 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
return None
|
||||
|
||||
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(token):
|
||||
is_oauth = _is_oauth_token(token)
|
||||
if is_oauth:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
||||
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
def _do_request(h: dict[str, str]):
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=h,
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
try:
|
||||
try:
|
||||
data = _do_request(headers)
|
||||
except urllib.error.HTTPError as http_err:
|
||||
# Reactive recovery for OAuth subscriptions that reject the 1M
|
||||
# context beta with 400 "long context beta is not yet available
|
||||
# for this subscription". Retry once without the beta; re-raise
|
||||
# anything else so the outer except logs it.
|
||||
if (
|
||||
is_oauth
|
||||
and http_err.code == 400
|
||||
):
|
||||
try:
|
||||
body_text = http_err.read().decode(errors="ignore").lower()
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if "long context beta" in body_text and "not yet available" in body_text:
|
||||
headers["anthropic-beta"] = ",".join(
|
||||
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
||||
+ list(_OAUTH_ONLY_BETAS)
|
||||
)
|
||||
data = _do_request(headers)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
|
||||
|
||||
+36
-2
@@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
|
||||
|
||||
def platform_label(key: str, default: str = "") -> str:
|
||||
"""Return the display label for a platform key, or *default*."""
|
||||
"""Return the display label for a platform key, or *default*.
|
||||
|
||||
Checks the static PLATFORMS dict first, then the plugin platform
|
||||
registry for dynamically registered platforms.
|
||||
"""
|
||||
info = PLATFORMS.get(key)
|
||||
return info.label if info is not None else default
|
||||
if info is not None:
|
||||
return info.label
|
||||
# Check plugin registry
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(key)
|
||||
if entry:
|
||||
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
|
||||
"""Return PLATFORMS merged with any plugin-registered platforms.
|
||||
|
||||
Plugin platforms are appended after builtins. This is the function
|
||||
that tools_config and skills_config should use for platform menus.
|
||||
"""
|
||||
merged = OrderedDict(PLATFORMS)
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.name not in merged:
|
||||
merged[entry.name] = PlatformInfo(
|
||||
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
|
||||
default_toolset=f"hermes-{entry.name}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return merged
|
||||
|
||||
+89
-6
@@ -37,6 +37,7 @@ import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
@@ -47,6 +48,19 @@ from hermes_constants import get_hermes_home
|
||||
from utils import env_var_enabled
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
def get_bundled_plugins_dir() -> Path:
|
||||
"""Locate the bundled ``plugins/`` directory.
|
||||
|
||||
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
|
||||
installs) so read-only store paths are consulted first. Falls back to
|
||||
the in-repo path used during development.
|
||||
"""
|
||||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).resolve().parent.parent / "plugins"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover – yaml is optional at import time
|
||||
@@ -156,7 +170,7 @@ def _get_enabled_plugins() -> Optional[set]:
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive"}
|
||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -182,6 +196,11 @@ class PluginManifest:
|
||||
# Selection via ``<category>.provider`` config key; the
|
||||
# category's own discovery system handles loading and the
|
||||
# general scanner skips these.
|
||||
# ``platform``: gateway messaging platform adapter (e.g. IRC). Bundled
|
||||
# platform plugins auto-load so every shipped platform is
|
||||
# available out of the box; user-installed platform plugins
|
||||
# in ~/.hermes/plugins/ still gated by ``plugins.enabled``
|
||||
# (untrusted code).
|
||||
kind: str = "standalone"
|
||||
# Registry key — path-derived, used by ``plugins.enabled``/``disabled``
|
||||
# lookups and by ``hermes plugins list``. For a flat plugin at
|
||||
@@ -445,6 +464,62 @@ class PluginContext:
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- platform adapter registration ---------------------------------------
|
||||
|
||||
def register_platform(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
adapter_factory: Callable,
|
||||
check_fn: Callable,
|
||||
validate_config: Callable | None = None,
|
||||
required_env: list | None = None,
|
||||
install_hint: str = "",
|
||||
**entry_kwargs: Any,
|
||||
) -> None:
|
||||
"""Register a gateway platform adapter.
|
||||
|
||||
The adapter_factory receives a ``PlatformConfig`` and returns a
|
||||
``BasePlatformAdapter`` subclass instance. The gateway calls
|
||||
``check_fn()`` before instantiation to verify dependencies.
|
||||
|
||||
Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g.
|
||||
``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``).
|
||||
Unknown keys raise TypeError from the dataclass constructor.
|
||||
|
||||
Example::
|
||||
|
||||
ctx.register_platform(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=lambda: True,
|
||||
emoji="💬",
|
||||
setup_fn=irc_interactive_setup,
|
||||
)
|
||||
"""
|
||||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||||
|
||||
entry_kwargs.setdefault("plugin_name", self.manifest.name)
|
||||
entry = PlatformEntry(
|
||||
name=name,
|
||||
label=label,
|
||||
adapter_factory=adapter_factory,
|
||||
check_fn=check_fn,
|
||||
validate_config=validate_config,
|
||||
required_env=required_env or [],
|
||||
install_hint=install_hint,
|
||||
source="plugin",
|
||||
**entry_kwargs,
|
||||
)
|
||||
platform_registry.register(entry)
|
||||
self._manager._plugin_platform_names.add(name)
|
||||
logger.debug(
|
||||
"Plugin %s registered platform: %s",
|
||||
self.manifest.name,
|
||||
name,
|
||||
)
|
||||
|
||||
# -- hook registration --------------------------------------------------
|
||||
|
||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||
@@ -523,6 +598,7 @@ class PluginManager:
|
||||
self._plugins: Dict[str, LoadedPlugin] = {}
|
||||
self._hooks: Dict[str, List[Callable]] = {}
|
||||
self._plugin_tool_names: Set[str] = set()
|
||||
self._plugin_platform_names: Set[str] = set()
|
||||
self._cli_commands: Dict[str, dict] = {}
|
||||
self._context_engine = None # Set by a plugin via register_context_engine()
|
||||
self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins
|
||||
@@ -565,16 +641,19 @@ class PluginManager:
|
||||
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
|
||||
#
|
||||
# ``memory/`` and ``context_engine/`` are skipped at the top level —
|
||||
# they have their own discovery systems. Porting those to the
|
||||
# category-namespace ``kind: exclusive`` model is a future PR.
|
||||
repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
|
||||
# they have their own discovery systems. ``platforms/`` is a category
|
||||
# holding platform adapters (scanned one level deeper below).
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
manifests.extend(
|
||||
self._scan_directory(
|
||||
repo_plugins,
|
||||
source="bundled",
|
||||
skip_names={"memory", "context_engine"},
|
||||
skip_names={"memory", "context_engine", "platforms"},
|
||||
)
|
||||
)
|
||||
manifests.extend(
|
||||
self._scan_directory(repo_plugins / "platforms", source="bundled")
|
||||
)
|
||||
|
||||
# 2. User plugins (~/.hermes/plugins/)
|
||||
user_dir = get_hermes_home() / "plugins"
|
||||
@@ -631,7 +710,11 @@ class PluginManager:
|
||||
# just work. Selection among them (e.g. which image_gen backend
|
||||
# services calls) is driven by ``<category>.provider`` config,
|
||||
# enforced by the tool wrapper.
|
||||
if manifest.kind == "backend" and manifest.source == "bundled":
|
||||
#
|
||||
# Bundled platform plugins (gateway adapters like IRC) auto-load
|
||||
# for the same reason: every platform Hermes ships must be
|
||||
# available out of the box without the user having to opt in.
|
||||
if manifest.source == "bundled" and manifest.kind in ("backend", "platform"):
|
||||
self._load_plugin(manifest)
|
||||
continue
|
||||
|
||||
|
||||
@@ -630,10 +630,9 @@ def _plugin_exists(name: str) -> bool:
|
||||
manifest = _read_manifest(child)
|
||||
if manifest.get("name") == name:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/
|
||||
from pathlib import Path as _P
|
||||
import hermes_cli
|
||||
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
@@ -660,8 +659,8 @@ def _discover_all_plugins() -> list:
|
||||
seen: dict = {} # name -> (name, version, description, source, path)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
import hermes_cli
|
||||
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
|
||||
@@ -585,6 +585,12 @@ def resolve_custom_provider(
|
||||
if not requested:
|
||||
return None
|
||||
|
||||
# If the stored provider is the bare string "custom" (corrupt state
|
||||
# from a prior model-switch bug), fall back to the first custom
|
||||
# provider entry so existing configs self-heal. (GH #17478)
|
||||
bare_custom_fallback = requested == "custom"
|
||||
first_valid = None
|
||||
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -599,6 +605,10 @@ def resolve_custom_provider(
|
||||
if not display_name or not api_url:
|
||||
continue
|
||||
|
||||
# Stash the first valid entry for bare-"custom" fallback
|
||||
if first_valid is None:
|
||||
first_valid = (display_name, api_url)
|
||||
|
||||
slug = custom_provider_slug(display_name)
|
||||
if requested not in {display_name.lower(), slug}:
|
||||
continue
|
||||
@@ -614,6 +624,21 @@ def resolve_custom_provider(
|
||||
source="user-config",
|
||||
)
|
||||
|
||||
# Self-heal: bare "custom" matched nothing — return first valid entry
|
||||
if bare_custom_fallback and first_valid:
|
||||
dname, aurl = first_valid
|
||||
slug = custom_provider_slug(dname)
|
||||
return ProviderDef(
|
||||
id=slug,
|
||||
name=dname,
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=(),
|
||||
base_url=aurl,
|
||||
is_aggregator=False,
|
||||
auth_type="api_key",
|
||||
source="user-config",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Unified self-relaunch for Hermes CLI.
|
||||
|
||||
Preserves critical flags (--tui, --dev, --profile, --model, etc.) across
|
||||
process replacement so that ``hermes sessions browse`` or post-setup relaunch
|
||||
doesn't silently drop the user's UI mode or other preferences.
|
||||
|
||||
Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from hermes_cli._parser import (
|
||||
PRE_ARGPARSE_INHERITED_FLAGS,
|
||||
build_top_level_parser,
|
||||
)
|
||||
|
||||
|
||||
def _build_inherited_flag_table() -> list[tuple[str, bool]]:
|
||||
"""Build the ``(option_string, takes_value)`` table of flags that must
|
||||
survive a self-relaunch, by introspecting the real parser used by
|
||||
``hermes`` itself.
|
||||
|
||||
A flag participates if its argparse Action carries
|
||||
``inherit_on_relaunch = True`` — set by ``_parser._inherited_flag``.
|
||||
"""
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
|
||||
table: list[tuple[str, bool]] = []
|
||||
seen: set[tuple[str, bool]] = set()
|
||||
for p in (parser, chat_parser):
|
||||
for action in p._actions:
|
||||
if not action.option_strings:
|
||||
continue # positional / no flag form
|
||||
if not getattr(action, "inherit_on_relaunch", False):
|
||||
continue
|
||||
takes_value = action.nargs != 0 # store_true/false set nargs=0
|
||||
for opt in action.option_strings:
|
||||
key = (opt, takes_value)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
table.append(key)
|
||||
|
||||
table.extend(PRE_ARGPARSE_INHERITED_FLAGS)
|
||||
return table
|
||||
|
||||
|
||||
_INHERITED_FLAGS_TABLE = _build_inherited_flag_table()
|
||||
|
||||
|
||||
def _extract_inherited_flags(argv: Sequence[str]) -> list[str]:
|
||||
"""Pull out flags that should carry over into a self-relaunched hermes."""
|
||||
flags: list[str] = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if "=" in arg:
|
||||
key = arg.split("=", 1)[0]
|
||||
for flag, _ in _INHERITED_FLAGS_TABLE:
|
||||
if key == flag:
|
||||
flags.append(arg)
|
||||
break
|
||||
i += 1
|
||||
continue
|
||||
|
||||
for flag, takes_value in _INHERITED_FLAGS_TABLE:
|
||||
if arg == flag:
|
||||
flags.append(arg)
|
||||
if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"):
|
||||
flags.append(argv[i + 1])
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
return flags
|
||||
|
||||
|
||||
def resolve_hermes_bin() -> Optional[str]:
|
||||
"""Find the hermes entry point.
|
||||
|
||||
Priority:
|
||||
1. ``sys.argv[0]`` if it resolves to a real executable.
|
||||
2. ``shutil.which("hermes")`` on PATH.
|
||||
3. ``None`` → caller should fall back to ``python -m hermes_cli.main``.
|
||||
"""
|
||||
argv0 = sys.argv[0]
|
||||
|
||||
# Absolute path to an executable (covers nix store, venv wrappers, etc.)
|
||||
if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK):
|
||||
return argv0
|
||||
|
||||
# Relative path — resolve against CWD
|
||||
if not argv0.startswith("-") and os.path.isfile(argv0):
|
||||
abs_path = os.path.abspath(argv0)
|
||||
if os.access(abs_path, os.X_OK):
|
||||
return abs_path
|
||||
|
||||
# PATH lookup
|
||||
path_bin = shutil.which("hermes")
|
||||
if path_bin:
|
||||
return path_bin
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_relaunch_argv(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> list[str]:
|
||||
"""Construct an argv list for replacing the current process with hermes.
|
||||
|
||||
Args:
|
||||
extra_args: Arguments to append (e.g. ``["--resume", id]``).
|
||||
preserve_inherited: Whether to carry over UI / behaviour flags
|
||||
tagged with ``inherit_on_relaunch`` in the parser.
|
||||
original_argv: The original argv to scan for flags (defaults to
|
||||
``sys.argv[1:]``).
|
||||
"""
|
||||
bin_path = resolve_hermes_bin()
|
||||
|
||||
if bin_path:
|
||||
argv = [bin_path]
|
||||
else:
|
||||
argv = [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
|
||||
|
||||
if preserve_inherited:
|
||||
argv.extend(_extract_inherited_flags(src))
|
||||
|
||||
argv.extend(extra_args)
|
||||
return argv
|
||||
|
||||
|
||||
def relaunch(
|
||||
extra_args: Sequence[str],
|
||||
*,
|
||||
preserve_inherited: bool = True,
|
||||
original_argv: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Replace the current process with a fresh hermes invocation."""
|
||||
new_argv = build_relaunch_argv(
|
||||
extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv
|
||||
)
|
||||
os.execvp(new_argv[0], new_argv)
|
||||
@@ -391,7 +391,14 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
"api_key": resolved_api_key,
|
||||
"model": entry.get("default_model", ""),
|
||||
}
|
||||
api_mode = _parse_api_mode(entry.get("api_mode"))
|
||||
# The v11→v12 migration writes the API mode under the new
|
||||
# ``transport`` field, but hand-edited configs may still
|
||||
# use the legacy ``api_mode`` spelling. Accept both —
|
||||
# the runtime normaliser ``_normalize_custom_provider_entry``
|
||||
# already does, so without this lift every migrated config
|
||||
# silently downgrades codex_responses / anthropic_messages
|
||||
# providers to chat_completions in the resolved runtime.
|
||||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
return result
|
||||
@@ -409,7 +416,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
||||
"api_key": resolved_api_key,
|
||||
"model": entry.get("default_model", ""),
|
||||
}
|
||||
api_mode = _parse_api_mode(entry.get("api_mode"))
|
||||
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
|
||||
if api_mode:
|
||||
result["api_mode"] = api_mode
|
||||
return result
|
||||
|
||||
+38
-153
@@ -2204,80 +2204,7 @@ def _setup_mattermost():
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_whatsapp():
|
||||
"""Configure WhatsApp bridge."""
|
||||
print_header("WhatsApp")
|
||||
existing = get_env_value("WHATSAPP_ENABLED")
|
||||
if existing:
|
||||
print_info("WhatsApp: already enabled")
|
||||
return
|
||||
|
||||
print_info("WhatsApp connects via a built-in bridge (Baileys).")
|
||||
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
|
||||
print()
|
||||
if prompt_yes_no("Enable WhatsApp now?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
print_success("WhatsApp enabled")
|
||||
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
|
||||
print_info("or personal self-chat) and pair via QR code.")
|
||||
|
||||
|
||||
def _setup_weixin():
|
||||
"""Configure Weixin (personal WeChat) via iLink Bot API QR login."""
|
||||
from hermes_cli.gateway import _setup_weixin as _gateway_setup_weixin
|
||||
_gateway_setup_weixin()
|
||||
|
||||
|
||||
def _setup_signal():
|
||||
"""Configure Signal via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_signal as _gateway_setup_signal
|
||||
_gateway_setup_signal()
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_email as _gateway_setup_email
|
||||
_gateway_setup_email()
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_sms as _gateway_setup_sms
|
||||
_gateway_setup_sms()
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_dingtalk as _gateway_setup_dingtalk
|
||||
_gateway_setup_dingtalk()
|
||||
|
||||
|
||||
def _setup_feishu():
|
||||
"""Configure Feishu / Lark via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_feishu as _gateway_setup_feishu
|
||||
_gateway_setup_feishu()
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
|
||||
_gateway_setup_yuanbao()
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
|
||||
_gateway_setup_wecom()
|
||||
|
||||
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom_callback as _gw_setup
|
||||
_gw_setup()
|
||||
|
||||
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
@@ -2395,49 +2322,27 @@ def _setup_webhooks():
|
||||
print_info(" https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/#configuring-routes")
|
||||
print()
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
# Platform registry for the gateway checklist
|
||||
_GATEWAY_PLATFORMS = [
|
||||
("Telegram", "TELEGRAM_BOT_TOKEN", _setup_telegram),
|
||||
("Discord", "DISCORD_BOT_TOKEN", _setup_discord),
|
||||
("Slack", "SLACK_BOT_TOKEN", _setup_slack),
|
||||
("Signal", "SIGNAL_HTTP_URL", _setup_signal),
|
||||
("Email", "EMAIL_ADDRESS", _setup_email),
|
||||
("SMS (Twilio)", "TWILIO_ACCOUNT_SID", _setup_sms),
|
||||
("Matrix", "MATRIX_ACCESS_TOKEN", _setup_matrix),
|
||||
("Mattermost", "MATTERMOST_TOKEN", _setup_mattermost),
|
||||
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("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),
|
||||
]
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def setup_gateway(config: dict):
|
||||
"""Configure messaging platform integrations."""
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status, _configure_platform
|
||||
|
||||
print_header("Messaging Platforms")
|
||||
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
|
||||
print_info("Toggle with Space, confirm with Enter.")
|
||||
print()
|
||||
|
||||
# Build checklist items, pre-selecting already-configured platforms
|
||||
platforms = _all_platforms()
|
||||
|
||||
# Build checklist, pre-selecting already-configured platforms.
|
||||
items = []
|
||||
pre_selected = []
|
||||
for i, (name, env_var, _func) in enumerate(_GATEWAY_PLATFORMS):
|
||||
# Matrix has two possible env vars
|
||||
is_configured = bool(get_env_value(env_var))
|
||||
if name == "Matrix" and not is_configured:
|
||||
is_configured = bool(get_env_value("MATRIX_PASSWORD"))
|
||||
label = f"{name} (configured)" if is_configured else name
|
||||
items.append(label)
|
||||
if is_configured:
|
||||
for i, plat in enumerate(platforms):
|
||||
status = _platform_status(plat)
|
||||
items.append(f"{plat['emoji']} {plat['label']} ({status})")
|
||||
if status == "configured":
|
||||
pre_selected.append(i)
|
||||
|
||||
selected = prompt_checklist("Select platforms to configure:", items, pre_selected)
|
||||
@@ -2447,28 +2352,22 @@ def setup_gateway(config: dict):
|
||||
return
|
||||
|
||||
for idx in selected:
|
||||
name, _env_var, setup_func = _GATEWAY_PLATFORMS[idx]
|
||||
setup_func()
|
||||
_configure_platform(platforms[idx])
|
||||
|
||||
# ── Gateway Service Setup ──
|
||||
any_messaging = (
|
||||
get_env_value("TELEGRAM_BOT_TOKEN")
|
||||
or get_env_value("DISCORD_BOT_TOKEN")
|
||||
or get_env_value("SLACK_BOT_TOKEN")
|
||||
or get_env_value("SIGNAL_HTTP_URL")
|
||||
or get_env_value("EMAIL_ADDRESS")
|
||||
or get_env_value("TWILIO_ACCOUNT_SID")
|
||||
or get_env_value("MATTERMOST_TOKEN")
|
||||
or get_env_value("MATRIX_ACCESS_TOKEN")
|
||||
or get_env_value("MATRIX_PASSWORD")
|
||||
or get_env_value("WHATSAPP_ENABLED")
|
||||
or get_env_value("DINGTALK_CLIENT_ID")
|
||||
or get_env_value("FEISHU_APP_ID")
|
||||
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")
|
||||
# Count any platform (built-in or plugin) the user configured during this
|
||||
# setup pass — reuses ``_platform_status`` so plugin platforms like IRC
|
||||
# are picked up without another hard-coded env-var list.
|
||||
def _is_progress(status: str) -> bool:
|
||||
s = status.lower()
|
||||
return not (
|
||||
s == "not configured"
|
||||
or s.startswith("partially")
|
||||
or s.startswith("plugin disabled")
|
||||
)
|
||||
|
||||
any_messaging = any(
|
||||
_is_progress(_platform_status(p)) for p in _all_platforms()
|
||||
)
|
||||
if any_messaging:
|
||||
print()
|
||||
@@ -2738,13 +2637,18 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]
|
||||
return f"max turns: {max_turns}"
|
||||
|
||||
elif section_key == "gateway":
|
||||
platforms = [
|
||||
_gateway_platform_short_label(label)
|
||||
for label, env_var, _ in _GATEWAY_PLATFORMS
|
||||
if get_env_value(env_var)
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status
|
||||
# Count any non-empty status other than the "not configured" sentinel —
|
||||
# platforms like WhatsApp ("enabled, not paired"), Matrix ("configured
|
||||
# + E2EE"), and Signal ("partially configured") all indicate the user
|
||||
# has already started setup and we shouldn't force the section to rerun.
|
||||
configured = [
|
||||
_gateway_platform_short_label(plat["label"])
|
||||
for plat in _all_platforms()
|
||||
if _platform_status(plat) and _platform_status(plat) != "not configured"
|
||||
]
|
||||
if platforms:
|
||||
return ", ".join(platforms)
|
||||
if configured:
|
||||
return ", ".join(configured)
|
||||
return None # No platforms configured — section must run
|
||||
|
||||
elif section_key == "tools":
|
||||
@@ -3249,33 +3153,14 @@ def run_setup_wizard(args):
|
||||
_offer_launch_chat()
|
||||
|
||||
|
||||
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
|
||||
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
return [hermes_bin, "chat"]
|
||||
|
||||
try:
|
||||
if importlib.util.find_spec("hermes_cli") is not None:
|
||||
return [sys.executable, "-m", "hermes_cli.main", "chat"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _offer_launch_chat():
|
||||
"""Prompt the user to jump straight into chat after setup."""
|
||||
print()
|
||||
if not prompt_yes_no("Launch hermes chat now?", True):
|
||||
return
|
||||
|
||||
chat_argv = _resolve_hermes_chat_argv()
|
||||
if not chat_argv:
|
||||
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
|
||||
return
|
||||
|
||||
os.execvp(chat_argv[0], chat_argv)
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(["chat"])
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
|
||||
+30
-19
@@ -91,12 +91,12 @@ def show_status(args):
|
||||
"""Show status of all Hermes Agent components."""
|
||||
show_all = getattr(args, 'all', False)
|
||||
deep = getattr(args, 'deep', False)
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Environment
|
||||
# =========================================================================
|
||||
@@ -104,7 +104,7 @@ def show_status(args):
|
||||
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Project: {PROJECT_ROOT}")
|
||||
print(f" Python: {sys.version.split()[0]}")
|
||||
|
||||
|
||||
env_path = get_env_path()
|
||||
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
||||
|
||||
@@ -115,13 +115,13 @@ def show_status(args):
|
||||
|
||||
print(f" Model: {_configured_model_label(config)}")
|
||||
print(f" Provider: {_effective_provider_label()}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API Keys
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
keys = {
|
||||
"OpenRouter": "OPENROUTER_API_KEY",
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
@@ -140,7 +140,7 @@ def show_status(args):
|
||||
"ElevenLabs": "ELEVENLABS_API_KEY",
|
||||
"GitHub": "GITHUB_TOKEN",
|
||||
}
|
||||
|
||||
|
||||
for name, env_var in keys.items():
|
||||
value = get_env_value(env_var) or ""
|
||||
has_key = bool(value)
|
||||
@@ -322,13 +322,13 @@ def show_status(args):
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "")
|
||||
if not terminal_env:
|
||||
terminal_env = terminal_cfg.get("backend", "local")
|
||||
print(f" Backend: {terminal_env}")
|
||||
|
||||
|
||||
if terminal_env == "ssh":
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
||||
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
||||
@@ -357,16 +357,16 @@ def show_status(args):
|
||||
print(f" Auth detail: {line}")
|
||||
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
||||
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
||||
|
||||
|
||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Messaging Platforms
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
platforms = {
|
||||
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
||||
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
||||
@@ -384,7 +384,7 @@ def show_status(args):
|
||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
token = os.getenv(token_var, "")
|
||||
has_token = bool(token)
|
||||
@@ -401,7 +401,18 @@ def show_status(args):
|
||||
status += f" (home: {home_channel})"
|
||||
|
||||
print(f" {name:<12} {check_mark(has_token)} {status}")
|
||||
|
||||
|
||||
# Plugin-registered platforms
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
configured = entry.check_fn()
|
||||
status_str = "configured" if configured else "not configured"
|
||||
label = entry.label
|
||||
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Gateway Status
|
||||
# =========================================================================
|
||||
@@ -437,13 +448,13 @@ def show_status(args):
|
||||
else:
|
||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||
print(" Manager: (not supported on this platform)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cron Jobs
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
@@ -457,13 +468,13 @@ def show_status(args):
|
||||
print(" Jobs: (error reading jobs file)")
|
||||
else:
|
||||
print(" Jobs: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sessions
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
@@ -475,7 +486,7 @@ def show_status(args):
|
||||
print(" Active: (error reading sessions file)")
|
||||
else:
|
||||
print(" Active: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Deep checks
|
||||
# =========================================================================
|
||||
@@ -511,7 +522,7 @@ def show_status(args):
|
||||
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
print()
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
||||
|
||||
@@ -100,6 +100,9 @@ TIPS = [
|
||||
"hermes gateway install sets up Hermes as a system service (systemd/launchd).",
|
||||
"hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).",
|
||||
"hermes webhook subscribe creates event-driven webhook routes with HMAC validation.",
|
||||
"Save money: hermes tools disables unused tools, hermes skills config trims skills down.",
|
||||
"/reasoning low or /reasoning minimal cuts thinking depth below the default (medium) — faster, cheaper responses.",
|
||||
"hermes models routes vision, compression, and aux tasks to cheaper models — cuts background token cost 85%+ without downgrading your main chat model.",
|
||||
|
||||
# --- Configuration ---
|
||||
"Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.",
|
||||
|
||||
@@ -227,6 +227,14 @@ TOOL_CATEGORIES = {
|
||||
"tts_provider": "kittentts",
|
||||
"post_setup": "kittentts",
|
||||
},
|
||||
{
|
||||
"name": "Piper",
|
||||
"badge": "local · free",
|
||||
"tag": "Local neural TTS, 44 languages (voices ~20-90MB)",
|
||||
"env_vars": [],
|
||||
"tts_provider": "piper",
|
||||
"post_setup": "piper",
|
||||
},
|
||||
],
|
||||
},
|
||||
"web": {
|
||||
@@ -624,6 +632,33 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_warning(" kittentts install timed out (>5min)")
|
||||
_print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile")
|
||||
|
||||
elif post_setup_key == "piper":
|
||||
try:
|
||||
__import__("piper")
|
||||
_print_success(" piper-tts is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" piper-tts installed")
|
||||
else:
|
||||
_print_warning(" piper-tts install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" piper-tts install timed out (>5min)")
|
||||
_print_info(" Run manually: python -m pip install -U piper-tts")
|
||||
return
|
||||
_print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)")
|
||||
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
|
||||
_print_info(" Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
|
||||
|
||||
elif post_setup_key == "spotify":
|
||||
# Run the full `hermes auth spotify` flow — if the user has no
|
||||
# client_id yet, this drops them into the interactive wizard
|
||||
@@ -781,7 +816,12 @@ def _get_platform_tools(
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
|
||||
if toolset_names is None or not isinstance(toolset_names, list):
|
||||
default_ts = PLATFORMS[platform]["default_toolset"]
|
||||
plat_info = PLATFORMS.get(platform)
|
||||
if plat_info:
|
||||
default_ts = plat_info["default_toolset"]
|
||||
else:
|
||||
# Plugin platform — derive toolset name from platform key
|
||||
default_ts = f"hermes-{platform}"
|
||||
toolset_names = [default_ts]
|
||||
|
||||
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
|
||||
@@ -844,7 +884,9 @@ def _get_platform_tools(
|
||||
# checklist or in a user-saved config. Must run in BOTH branches —
|
||||
# otherwise saving via `hermes tools` (which flips has_explicit_config
|
||||
# to True) silently drops them.
|
||||
platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"]))
|
||||
_plat_info = PLATFORMS.get(platform)
|
||||
_default_ts = _plat_info["default_toolset"] if _plat_info else f"hermes-{platform}"
|
||||
platform_tool_universe = set(resolve_toolset(_default_ts))
|
||||
configurable_tool_universe = set()
|
||||
for ck in configurable_keys:
|
||||
configurable_tool_universe.update(resolve_toolset(ck))
|
||||
|
||||
+331
-7
@@ -23,7 +23,7 @@ import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -345,6 +345,10 @@ _CATEGORY_MERGE: Dict[str, str] = {
|
||||
"dashboard": "display",
|
||||
"code_execution": "agent",
|
||||
"prompt_caching": "agent",
|
||||
# Only `telegram.reactions` currently lives under telegram — fold it in
|
||||
# with the other messaging-platform config (discord) so it isn't an
|
||||
# orphan tab of one field.
|
||||
"telegram": "discord",
|
||||
}
|
||||
|
||||
# Display order for tabs — unlisted categories sort alphabetically after these.
|
||||
@@ -441,6 +445,20 @@ class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
scope="main" → writes model.provider + model.default
|
||||
scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model
|
||||
scope="auxiliary" with task="" → applied to every auxiliary.* slot
|
||||
scope="auxiliary" with task="__reset__" → resets every slot to provider="auto"
|
||||
"""
|
||||
scope: str
|
||||
provider: str
|
||||
model: str
|
||||
task: str = ""
|
||||
|
||||
|
||||
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
|
||||
try:
|
||||
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
|
||||
@@ -917,6 +935,207 @@ def get_model_info():
|
||||
return dict(_EMPTY_MODEL_INFO)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model assignment — pick provider+model for main slot or auxiliary slots.
|
||||
# Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the
|
||||
# Models page (which has no chat PTY open) can drive it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"]
|
||||
# in hermes_cli/config.py — listed here for deterministic ordering in the UI.
|
||||
_AUX_TASK_SLOTS: Tuple[str, ...] = (
|
||||
"vision",
|
||||
"web_extract",
|
||||
"compression",
|
||||
"session_search",
|
||||
"skills_hub",
|
||||
"approval",
|
||||
"mcp",
|
||||
"title_generation",
|
||||
"curator",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/model/options")
|
||||
def get_model_options():
|
||||
"""Return authenticated providers + their curated model lists.
|
||||
|
||||
REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
|
||||
dashboard Models page can render the picker without a live chat session.
|
||||
The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog``
|
||||
can share the same types.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
current_model = model_cfg.get("default", model_cfg.get("name", "")) or ""
|
||||
current_provider = model_cfg.get("provider", "") or ""
|
||||
current_base_url = model_cfg.get("base_url", "") or ""
|
||||
else:
|
||||
current_model = str(model_cfg) if model_cfg else ""
|
||||
current_provider = ""
|
||||
current_base_url = ""
|
||||
|
||||
user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
|
||||
custom_providers = (
|
||||
cfg.get("custom_providers")
|
||||
if isinstance(cfg.get("custom_providers"), list)
|
||||
else []
|
||||
)
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
current_model=current_model,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=50,
|
||||
)
|
||||
return {
|
||||
"providers": providers,
|
||||
"model": current_model,
|
||||
"provider": current_provider,
|
||||
}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to list model options")
|
||||
|
||||
|
||||
@app.get("/api/model/auxiliary")
|
||||
def get_auxiliary_models():
|
||||
"""Return current auxiliary task assignments.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"tasks": [
|
||||
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
|
||||
...
|
||||
],
|
||||
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
|
||||
}
|
||||
"""
|
||||
try:
|
||||
cfg = load_config()
|
||||
aux_cfg = cfg.get("auxiliary", {})
|
||||
if not isinstance(aux_cfg, dict):
|
||||
aux_cfg = {}
|
||||
|
||||
tasks = []
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {}
|
||||
tasks.append({
|
||||
"task": slot,
|
||||
"provider": str(slot_cfg.get("provider", "auto") or "auto"),
|
||||
"model": str(slot_cfg.get("model", "") or ""),
|
||||
"base_url": str(slot_cfg.get("base_url", "") or ""),
|
||||
})
|
||||
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
main = {
|
||||
"provider": str(model_cfg.get("provider", "") or ""),
|
||||
"model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""),
|
||||
}
|
||||
else:
|
||||
main = {"provider": "", "model": str(model_cfg) if model_cfg else ""}
|
||||
|
||||
return {"tasks": tasks, "main": main}
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/auxiliary failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to read auxiliary config")
|
||||
|
||||
|
||||
@app.post("/api/model/set")
|
||||
async def set_model_assignment(body: ModelAssignment):
|
||||
"""Assign a model to the main slot or an auxiliary task slot.
|
||||
|
||||
Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only.
|
||||
The currently running chat PTY (if any) is not affected; use the
|
||||
``/model`` slash command inside a chat to hot-swap that specific session.
|
||||
"""
|
||||
scope = (body.scope or "").strip().lower()
|
||||
provider = (body.provider or "").strip()
|
||||
model = (body.model or "").strip()
|
||||
task = (body.task or "").strip().lower()
|
||||
|
||||
if scope not in ("main", "auxiliary"):
|
||||
raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'")
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
|
||||
if scope == "main":
|
||||
if not provider or not model:
|
||||
raise HTTPException(status_code=400, detail="provider and model required for main")
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
# Clear stale base_url so the resolver picks the provider's own default.
|
||||
if "base_url" in model_cfg and model_cfg.get("base_url"):
|
||||
model_cfg["base_url"] = ""
|
||||
# Also clear hardcoded context_length override — new model may have
|
||||
# a different context window.
|
||||
if "context_length" in model_cfg:
|
||||
model_cfg.pop("context_length", None)
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "main", "provider": provider, "model": model}
|
||||
|
||||
# scope == "auxiliary"
|
||||
aux = cfg.get("auxiliary")
|
||||
if not isinstance(aux, dict):
|
||||
aux = {}
|
||||
|
||||
if task == "__reset__":
|
||||
# Reset every slot to provider="auto", model="" — keeps other fields intact.
|
||||
for slot in _AUX_TASK_SLOTS:
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = "auto"
|
||||
slot_cfg["model"] = ""
|
||||
aux[slot] = slot_cfg
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {"ok": True, "scope": "auxiliary", "reset": True}
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=400, detail="provider required for auxiliary")
|
||||
|
||||
targets = [task] if task else list(_AUX_TASK_SLOTS)
|
||||
for slot in targets:
|
||||
if slot not in _AUX_TASK_SLOTS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}")
|
||||
slot_cfg = aux.get(slot)
|
||||
if not isinstance(slot_cfg, dict):
|
||||
slot_cfg = {}
|
||||
slot_cfg["provider"] = provider
|
||||
slot_cfg["model"] = model
|
||||
aux[slot] = slot_cfg
|
||||
|
||||
cfg["auxiliary"] = aux
|
||||
save_config(cfg)
|
||||
return {
|
||||
"ok": True,
|
||||
"scope": "auxiliary",
|
||||
"tasks": targets,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("POST /api/model/set failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to save model assignment")
|
||||
|
||||
|
||||
|
||||
|
||||
def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reverse _normalize_config_for_web before saving.
|
||||
|
||||
@@ -2295,6 +2514,99 @@ async def get_usage_analytics(days: int = 30):
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/analytics/models")
|
||||
async def get_models_analytics(days: int = 30):
|
||||
"""Rich per-model analytics for the Models dashboard page.
|
||||
|
||||
Returns token/cost/session breakdown per model plus capability metadata
|
||||
from models.dev (context window, vision, tools, reasoning, etc.).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
|
||||
cur = db._conn.execute("""
|
||||
SELECT model,
|
||||
billing_provider,
|
||||
SUM(input_tokens) as input_tokens,
|
||||
SUM(output_tokens) as output_tokens,
|
||||
SUM(cache_read_tokens) as cache_read_tokens,
|
||||
SUM(reasoning_tokens) as reasoning_tokens,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as actual_cost,
|
||||
COUNT(*) as sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as api_calls,
|
||||
SUM(tool_call_count) as tool_calls,
|
||||
MAX(started_at) as last_used_at,
|
||||
AVG(input_tokens + output_tokens) as avg_tokens_per_session
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
GROUP BY model, billing_provider
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||
""", (cutoff,))
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
models = []
|
||||
for row in rows:
|
||||
provider = row.get("billing_provider") or ""
|
||||
model_name = row["model"]
|
||||
caps = {}
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
mc = get_model_capabilities(provider=provider, model=model_name)
|
||||
if mc is not None:
|
||||
caps = {
|
||||
"supports_tools": mc.supports_tools,
|
||||
"supports_vision": mc.supports_vision,
|
||||
"supports_reasoning": mc.supports_reasoning,
|
||||
"context_window": mc.context_window,
|
||||
"max_output_tokens": mc.max_output_tokens,
|
||||
"model_family": mc.model_family,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
models.append({
|
||||
"model": model_name,
|
||||
"provider": provider,
|
||||
"input_tokens": row["input_tokens"],
|
||||
"output_tokens": row["output_tokens"],
|
||||
"cache_read_tokens": row["cache_read_tokens"],
|
||||
"reasoning_tokens": row["reasoning_tokens"],
|
||||
"estimated_cost": row["estimated_cost"],
|
||||
"actual_cost": row["actual_cost"],
|
||||
"sessions": row["sessions"],
|
||||
"api_calls": row["api_calls"],
|
||||
"tool_calls": row["tool_calls"],
|
||||
"last_used_at": row["last_used_at"],
|
||||
"avg_tokens_per_session": row["avg_tokens_per_session"],
|
||||
"capabilities": caps,
|
||||
})
|
||||
|
||||
totals_cur = db._conn.execute("""
|
||||
SELECT COUNT(DISTINCT model) as distinct_models,
|
||||
SUM(input_tokens) as total_input,
|
||||
SUM(output_tokens) as total_output,
|
||||
SUM(cache_read_tokens) as total_cache_read,
|
||||
SUM(reasoning_tokens) as total_reasoning,
|
||||
COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost,
|
||||
COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost,
|
||||
COUNT(*) as total_sessions,
|
||||
SUM(COALESCE(api_call_count, 0)) as total_api_calls
|
||||
FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != ''
|
||||
""", (cutoff,))
|
||||
totals = dict(totals_cur.fetchone())
|
||||
|
||||
return {
|
||||
"models": models,
|
||||
"totals": totals,
|
||||
"period_days": days,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab.
|
||||
#
|
||||
@@ -2977,10 +3289,12 @@ def _discover_dashboard_plugins() -> list:
|
||||
plugins = []
|
||||
seen_names: set = set()
|
||||
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
bundled_root = get_bundled_plugins_dir()
|
||||
search_dirs = [
|
||||
(get_hermes_home() / "plugins", "user"),
|
||||
(PROJECT_ROOT / "plugins" / "memory", "bundled"),
|
||||
(PROJECT_ROOT / "plugins", "bundled"),
|
||||
(bundled_root / "memory", "bundled"),
|
||||
(bundled_root, "bundled"),
|
||||
]
|
||||
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
|
||||
@@ -3125,13 +3439,23 @@ def _mount_plugin_api_routes():
|
||||
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
|
||||
continue
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_dashboard_plugin_{plugin['name']}", api_path,
|
||||
)
|
||||
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, api_path)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI
|
||||
# can resolve forward references (e.g. models defined in a file
|
||||
# that uses `from __future__ import annotations`). Without this,
|
||||
# TypeAdapter lazy-build fails at first request with
|
||||
# "is not fully defined" because the module namespace isn't
|
||||
# reachable by name for string-annotation resolution.
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
router = getattr(mod, "router", None)
|
||||
if router is None:
|
||||
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
|
||||
|
||||
@@ -320,7 +320,15 @@ def get_tool_definitions(
|
||||
|
||||
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
|
||||
if quiet_mode:
|
||||
# Cache the freshly-computed list, but hand callers a shallow copy so
|
||||
# downstream mutations (e.g. run_agent appending memory/LCM tool
|
||||
# schemas to self.tools) don't poison the cache. Without this, a
|
||||
# long-lived Gateway process accumulates duplicate tool names across
|
||||
# agent inits and providers that enforce unique tool names
|
||||
# (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with
|
||||
# HTTP 400. Mirrors the cache-hit path above. (issue #17335)
|
||||
_tool_defs_cache[cache_key] = result
|
||||
return list(result)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+23
-3
@@ -4,9 +4,9 @@
|
||||
# transitive deps like onnxruntime that lack compatible wheels on
|
||||
# aarch64-darwin. The package and devShell still work on macOS.
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, lib, ... }:
|
||||
perSystem = { pkgs, lib, self', ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermes-agent = self'.packages.default;
|
||||
hermesVenv = hermes-agent.hermesVenv;
|
||||
|
||||
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
||||
@@ -51,7 +51,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
failMsg = lib.concatMapStringsSep "\n" (r: " - ${r.sys}") failures;
|
||||
in pkgs.runCommand "hermes-cross-eval" { } (
|
||||
if failures != [] then
|
||||
builtins.throw "Package fails to evaluate on:\n${failMsg}"
|
||||
throw "Package fails to evaluate on:\n${failMsg}"
|
||||
else ''
|
||||
echo "PASS: package evaluates on all ${toString (builtins.length targetSystems)} platforms"
|
||||
mkdir -p $out
|
||||
@@ -124,6 +124,26 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify bundled plugins (platforms, memory, context_engine) are present
|
||||
bundled-plugins = pkgs.runCommand "hermes-bundled-plugins" { } ''
|
||||
set -e
|
||||
echo "=== Checking bundled plugins ==="
|
||||
test -d ${hermes-agent}/share/hermes-agent/plugins || (echo "FAIL: plugins directory missing"; exit 1)
|
||||
echo "PASS: plugins directory exists"
|
||||
|
||||
test -f ${hermes-agent}/share/hermes-agent/plugins/platforms/irc/plugin.yaml || \
|
||||
(echo "FAIL: irc plugin manifest missing"; exit 1)
|
||||
echo "PASS: irc plugin manifest present"
|
||||
|
||||
grep -q "HERMES_BUNDLED_PLUGINS" ${hermes-agent}/bin/hermes || \
|
||||
(echo "FAIL: HERMES_BUNDLED_PLUGINS not in wrapper"; exit 1)
|
||||
echo "PASS: HERMES_BUNDLED_PLUGINS set in wrapper"
|
||||
|
||||
echo "=== All bundled plugins checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify bundled TUI is present and compiled
|
||||
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
|
||||
set -e
|
||||
|
||||
+19
-18
@@ -1,29 +1,30 @@
|
||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||
#
|
||||
# Each package in inputsFrom exposes passthru.devShellHook — a bash snippet
|
||||
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
|
||||
# with stamp-checked setup logic. This file collects and runs them all.
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, ... }:
|
||||
{ ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, self', ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermes-tui = inputs.self.packages.${system}.tui;
|
||||
hermes-web = inputs.self.packages.${system}.web;
|
||||
packages = [ hermes-agent hermes-tui hermes-web ];
|
||||
in {
|
||||
packages = builtins.attrValues self'.packages;
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
inputsFrom = packages;
|
||||
packages = with pkgs; [
|
||||
python312 uv nodejs_22 ripgrep git openssh ffmpeg
|
||||
uv
|
||||
];
|
||||
|
||||
shellHook = let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in ''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
shellHook =
|
||||
let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in
|
||||
''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+66
-44
@@ -19,16 +19,21 @@
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
npm-lockfile-fix,
|
||||
# Locked git revision of the flake source — embedded so banner.py can
|
||||
# check for updates without needing a local .git directory. Null for
|
||||
# impure / dirty builds where flakes can't determine a rev.
|
||||
rev ? null,
|
||||
# Overridable parameters
|
||||
extraPythonPackages ? [ ],
|
||||
}:
|
||||
let
|
||||
nodejs = nodejs_22;
|
||||
hermesVenv = callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
hermesNpmLib = callPackage ./lib.nix {
|
||||
inherit npm-lockfile-fix;
|
||||
inherit npm-lockfile-fix nodejs;
|
||||
};
|
||||
|
||||
hermesTui = callPackage ./tui.nix {
|
||||
@@ -44,8 +49,16 @@ let
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path);
|
||||
};
|
||||
|
||||
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
|
||||
# them out of the Python site-packages keeps import semantics identical
|
||||
# to a dev checkout — the loader reads them from HERMES_BUNDLED_PLUGINS.
|
||||
bundledPlugins = lib.cleanSourceWith {
|
||||
src = ../plugins;
|
||||
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = [
|
||||
nodejs_22
|
||||
nodejs
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
@@ -70,10 +83,49 @@ let
|
||||
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
|
||||
else
|
||||
"none";
|
||||
checkPackageCollisions = ''
|
||||
import pathlib, sys, re
|
||||
|
||||
def canonical(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
# Collect core venv package names
|
||||
core = set()
|
||||
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
|
||||
for di in venv_sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if meta.exists():
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
core.add(canonical(line.split(':', 1)[1].strip()))
|
||||
break
|
||||
|
||||
# Check each extra package for collisions
|
||||
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
|
||||
for edir in extras_dirs:
|
||||
sp = pathlib.Path(edir) / '${sitePackagesPath}'
|
||||
if not sp.exists():
|
||||
continue
|
||||
for di in sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if not meta.exists():
|
||||
continue
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
pkg = canonical(line.split(':', 1)[1].strip())
|
||||
if pkg in core:
|
||||
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
|
||||
print(f' from: {di}', file=sys.stderr)
|
||||
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
break
|
||||
|
||||
print('No collisions found.')
|
||||
'';
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
@@ -84,6 +136,7 @@ stdenv.mkDerivation {
|
||||
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
cp -r ${bundledPlugins} $out/share/hermes-agent/plugins
|
||||
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
|
||||
mkdir -p $out/ui-tui
|
||||
@@ -94,10 +147,12 @@ stdenv.mkDerivation {
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
|
||||
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${nodejs_22}/bin/node \
|
||||
--set HERMES_NODE ${lib.getExe nodejs} \
|
||||
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
|
||||
'')
|
||||
[
|
||||
@@ -109,45 +164,7 @@ stdenv.mkDerivation {
|
||||
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''
|
||||
echo "=== Checking for plugin/core package collisions ==="
|
||||
${hermesVenv}/bin/python3 -c "
|
||||
import pathlib, sys, re
|
||||
|
||||
def canonical(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
# Collect core venv package names
|
||||
core = set()
|
||||
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
|
||||
for di in venv_sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if meta.exists():
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
core.add(canonical(line.split(':', 1)[1].strip()))
|
||||
break
|
||||
|
||||
# Check each extra package for collisions
|
||||
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
|
||||
for edir in extras_dirs:
|
||||
sp = pathlib.Path(edir) / '${sitePackagesPath}'
|
||||
if not sp.exists():
|
||||
continue
|
||||
for di in sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if not meta.exists():
|
||||
continue
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
pkg = canonical(line.split(':', 1)[1].strip())
|
||||
if pkg in core:
|
||||
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
|
||||
print(f' from: {di}', file=sys.stderr)
|
||||
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
break
|
||||
|
||||
print('No collisions found.')
|
||||
"
|
||||
${hermesVenv}/bin/python3 -c "${checkPackageCollisions}"
|
||||
echo "=== No collisions ==="
|
||||
''}
|
||||
|
||||
@@ -155,7 +172,12 @@ print('No collisions found.')
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit hermesTui hermesWeb hermesNpmLib hermesVenv;
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
;
|
||||
|
||||
devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-agent"
|
||||
|
||||
+16
-9
@@ -1,11 +1,16 @@
|
||||
# nix/lib.nix — Shared helpers for nix stuff
|
||||
{ pkgs, npm-lockfile-fix }:
|
||||
{
|
||||
pkgs,
|
||||
npm-lockfile-fix,
|
||||
nodejs,
|
||||
}:
|
||||
{
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
# patchPhase — ensures lockfile has exactly one trailing newline
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# patchPhase — ensures lockfile has exactly one trailing newline
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# passthru.devShellHook — stamp-checked npm install + hash auto-update
|
||||
# passthru.npmLockfile — metadata for mkFixLockfiles
|
||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||
#
|
||||
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
|
||||
# npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing
|
||||
@@ -24,6 +29,7 @@
|
||||
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
|
||||
}:
|
||||
{
|
||||
inherit nodejs;
|
||||
patchPhase = ''
|
||||
runHook prePatch
|
||||
# Normalize trailing newlines so source and npm-deps always match,
|
||||
@@ -56,8 +62,8 @@
|
||||
|
||||
cd "$REPO_ROOT/${folder}"
|
||||
rm -rf node_modules/
|
||||
npm cache clean --force
|
||||
CI=true npm install
|
||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
||||
@@ -83,7 +89,7 @@
|
||||
STAMP_VALUE="$(_hermes_npm_stamp)"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "${pname}: installing npm dependencies..."
|
||||
( cd ${folder} && CI=true npm install --silent --no-fund --no-audit 2>/dev/null )
|
||||
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
|
||||
|
||||
# Auto-update the nix hash so it stays in sync with the lockfile
|
||||
echo "${pname}: prefetching npm deps..."
|
||||
@@ -92,7 +98,7 @@
|
||||
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
echo "${pname}: updated hash to $NEW_HASH"
|
||||
else
|
||||
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles -- --apply' manually" >&2
|
||||
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
|
||||
fi
|
||||
|
||||
mkdir -p .nix-stamps
|
||||
@@ -112,6 +118,7 @@
|
||||
# Invocations:
|
||||
# fix-lockfiles --check # exit 1 if any hash is stale
|
||||
# fix-lockfiles --apply # rewrite stale hashes in place
|
||||
# fix-lockfiles # alias of --apply
|
||||
# Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT
|
||||
# when set, so CI workflows can post a sticky PR comment directly.
|
||||
mkFixLockfiles =
|
||||
@@ -124,7 +131,7 @@
|
||||
in
|
||||
pkgs.writeShellScriptBin "fix-lockfiles" ''
|
||||
set -uox pipefail
|
||||
MODE="''${1:---check}"
|
||||
MODE="''${1:---apply}"
|
||||
case "$MODE" in
|
||||
--check|--apply) ;;
|
||||
-h|--help)
|
||||
@@ -222,7 +229,7 @@
|
||||
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
|
||||
echo
|
||||
echo "Stale lockfile hashes detected. Run:"
|
||||
echo " nix run .#fix-lockfiles -- --apply"
|
||||
echo " nix run .#fix-lockfiles"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+18
-8
@@ -647,6 +647,16 @@
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Assertions ─────────────────────────────────────────────────────
|
||||
{
|
||||
assertions = let
|
||||
names = map lib.getName cfg.extraPlugins;
|
||||
in [{
|
||||
assertion = (lib.length names) == (lib.length (lib.unique names));
|
||||
message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Warnings ──────────────────────────────────────────────────────
|
||||
# ── Per-user profile for extraPackages ───────────────────────────
|
||||
# Wire extraPackages into the hermes user's per-user profile so the
|
||||
@@ -730,12 +740,12 @@
|
||||
# is disabled so the host CLI falls back to native execution.
|
||||
${if cfg.container.enable then ''
|
||||
cat > ${cfg.stateDir}/.hermes/.container-mode <<'HERMES_CONTAINER_MODE_EOF'
|
||||
# Written by NixOS activation script. Do not edit manually.
|
||||
backend=${cfg.container.backend}
|
||||
container_name=${containerName}
|
||||
exec_user=${cfg.user}
|
||||
hermes_bin=${containerDataDir}/current-package/bin/hermes
|
||||
HERMES_CONTAINER_MODE_EOF
|
||||
# Written by NixOS activation script. Do not edit manually.
|
||||
backend=${cfg.container.backend}
|
||||
container_name=${containerName}
|
||||
exec_user=${cfg.user}
|
||||
hermes_bin=${containerDataDir}/current-package/bin/hermes
|
||||
HERMES_CONTAINER_MODE_EOF
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.container-mode
|
||||
chmod 0644 ${cfg.stateDir}/.hermes/.container-mode
|
||||
'' else ''
|
||||
@@ -796,8 +806,8 @@ HERMES_CONTAINER_MODE_EOF
|
||||
ENV_FILE="${cfg.stateDir}/.hermes/.env"
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 /dev/null "$ENV_FILE"
|
||||
cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
|
||||
${envFileContent}
|
||||
HERMES_NIX_ENV_EOF
|
||||
${envFileContent}
|
||||
HERMES_NIX_ENV_EOF
|
||||
${lib.concatStringsSep "\n" (map (f: ''
|
||||
if [ -f "${f}" ]; then
|
||||
echo "" >> "$ENV_FILE"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
hermes-agent = final.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
# Only embed clean revs — dirtyRev doesn't represent any upstream
|
||||
# commit, so comparing it would always claim "update available".
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
in
|
||||
{
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0=";
|
||||
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
|
||||
+5
-3
@@ -4,15 +4,17 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE=";
|
||||
hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-web";
|
||||
version = "0.0.0";
|
||||
inherit src npmDeps;
|
||||
inherit src npmDeps version;
|
||||
|
||||
doCheck = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hermes Achievements contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Hermes Achievements
|
||||
|
||||
> **Bundled with Hermes Agent.** Originally authored by [@PCinkusz](https://github.com/PCinkusz) at https://github.com/PCinkusz/hermes-achievements — vendored into `plugins/hermes-achievements/` so it ships with the dashboard out-of-the-box and stays in lockstep with Hermes feature changes. Upstream repo remains the staging ground for new badges and UI iteration.
|
||||
>
|
||||
> When Hermes is installed via `pip install hermes-agent` or cloned from source, this plugin auto-registers as a dashboard tab on first `hermes dashboard` launch. No separate install step. See [Built-in Plugins → hermes-achievements](../../website/docs/user-guide/features/built-in-plugins.md) in the main docs.
|
||||
|
||||
Achievement system for the Hermes Dashboard: collectible, tiered badges generated from real local Hermes session history.
|
||||
|
||||

|
||||
|
||||
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
|
||||
|
||||
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
|
||||
|
||||
## What it does
|
||||
|
||||
Hermes Achievements scans local Hermes sessions and unlocks badges based on real agent behavior:
|
||||
|
||||
- autonomous tool chains
|
||||
- debugging and recovery patterns
|
||||
- vibe-coding file edits
|
||||
- Hermes-native skills, memory, cron, and plugin usage
|
||||
- web research and browser automation
|
||||
- model/provider workflows
|
||||
- lifestyle patterns such as weekend or night sessions
|
||||
|
||||
Achievements have three visible states:
|
||||
|
||||
- **Unlocked** — earned at least one tier
|
||||
- **Discovered** — known achievement, progress visible, not earned yet
|
||||
- **Secret** — hidden until Hermes detects the first related signal
|
||||
|
||||
Most achievements level through:
|
||||
|
||||
```text
|
||||
Copper → Silver → Gold → Diamond → Olympian
|
||||
```
|
||||
|
||||
Each card has a collapsible **What counts** section showing the exact tracked metric or requirement once the user wants details.
|
||||
|
||||
Version `0.2.x` expands the catalog to 60+ achievements, including model/provider badges such as **Five-Model Flight**, **Provider Polyglot**, **Claude Confidant**, **Gemini Cartographer**, and **Open Weights Pilgrim**.
|
||||
|
||||
## Examples
|
||||
|
||||
- Let Him Cook
|
||||
- Toolchain Maxxer
|
||||
- Red Text Connoisseur
|
||||
- Port 3000 Is Taken
|
||||
- This Was Supposed To Be Quick
|
||||
- One More Small Change
|
||||
- Skillsmith
|
||||
- Memory Keeper
|
||||
- Context Dragon
|
||||
- Plugin Goblin
|
||||
- Rabbit Hole Certified
|
||||
|
||||
## Install
|
||||
|
||||
Clone into your Hermes plugins directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
For local development, keep the repo elsewhere and symlink it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/hermes-achievements
|
||||
ln -s ~/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
Then rescan dashboard plugins:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
|
||||
|
||||
## Updating
|
||||
|
||||
If you installed with git:
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/plugins/hermes-achievements
|
||||
git pull --ff-only
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
|
||||
|
||||
As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
|
||||
- removed duplicate `/overview` scan path
|
||||
- added cached `/achievements` snapshot
|
||||
- added incremental checkpoint reuse for unchanged sessions
|
||||
|
||||
Achievement unlock state is stored locally in `state.json` and is not overwritten by git updates. New achievements are evaluated from your existing Hermes session history. Achievement IDs are stable and should not be renamed casually because they are the unlock-state keys.
|
||||
|
||||
Releases are tagged in git, for example:
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v0.2.0
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
dashboard/
|
||||
├── manifest.json
|
||||
├── plugin_api.py
|
||||
└── dist/
|
||||
├── index.js
|
||||
└── style.css
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Routes are mounted under:
|
||||
|
||||
```text
|
||||
/api/plugins/hermes-achievements/
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
```text
|
||||
GET /achievements
|
||||
GET /scan-status
|
||||
GET /recent-unlocks
|
||||
GET /sessions/{session_id}/badges
|
||||
POST /rescan
|
||||
POST /reset-state
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run checks:
|
||||
|
||||
```bash
|
||||
node --check dashboard/dist/index.js
|
||||
python3 -m py_compile dashboard/plugin_api.py
|
||||
python3 -m unittest tests/test_achievement_engine.py -v
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+351
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
||||
/* hermes-achievements dashboard styles
|
||||
* Originally authored by @PCinkusz — https://github.com/PCinkusz/hermes-achievements (MIT).
|
||||
* Bundled into hermes-agent. The in-progress scan banner rules at the bottom
|
||||
* (.ha-scan-banner*) are a small addition layered on top of the original bundle.
|
||||
*/
|
||||
.ha-page { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.ha-hero { position: relative; overflow: hidden; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border: 1px solid var(--color-border); background: radial-gradient(circle at 12% 0, rgba(103,232,249,.13), transparent 30%), linear-gradient(135deg, color-mix(in srgb, var(--color-card) 88%, transparent), color-mix(in srgb, var(--color-primary) 10%, transparent)); padding: 1.25rem; }
|
||||
.ha-hero:before { content: ""; position: absolute; inset: auto -10% -80% -10%; height: 180%; pointer-events: none; background: radial-gradient(circle, rgba(242,201,76,.12), transparent 55%); }
|
||||
.ha-hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .9; letter-spacing: -0.06em; }
|
||||
.ha-hero p { position: relative; max-width: 52rem; margin: .65rem 0 0; color: var(--color-muted-foreground); }
|
||||
.ha-kicker { position: relative; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: .18em; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-refresh { position: relative; white-space: nowrap; }
|
||||
.ha-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
|
||||
.ha-stat-content { padding: 1rem !important; }
|
||||
.ha-stat-label { color: var(--color-muted-foreground); font-size: .75rem; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.ha-stat-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 750; letter-spacing: -0.035em; }
|
||||
.ha-stat-hint { margin-top: .2rem; color: var(--color-muted-foreground); font-size: .75rem; }
|
||||
.ha-toolbar { display: flex; justify-content: space-between; gap: .75rem; align-items: center; flex-wrap: wrap; }
|
||||
.ha-pills { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.ha-pills button { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-muted-foreground); padding: .35rem .6rem; font-size: .78rem; cursor: pointer; }
|
||||
.ha-pills button.active, .ha-pills button:hover { color: var(--color-foreground); border-color: var(--ha-tier, var(--color-ring)); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
|
||||
.ha-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: .9rem; }
|
||||
.ha-card { --ha-tier: var(--color-border); position: relative; overflow: hidden; min-height: 214px; border: 1px solid color-mix(in srgb, var(--ha-tier) 46%, var(--color-border)); background: radial-gradient(circle at 2.6rem 2.2rem, color-mix(in srgb, var(--ha-tier) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,.04), transparent), color-mix(in srgb, var(--color-card) 92%, #000); transition: transform .16s ease, border-color .16s ease, opacity .16s ease, box-shadow .16s ease; }
|
||||
.ha-card:hover { transform: translateY(-2px); border-color: var(--ha-tier); box-shadow: 0 0 0 1px color-mix(in srgb, var(--ha-tier) 16%, transparent); }
|
||||
.ha-card-content { position: relative; z-index: 1; padding: 1rem !important; display: flex; flex-direction: column; gap: .75rem; height: 100%; }
|
||||
.ha-card-head { display: grid; grid-template-columns: 3.1rem minmax(0, 1fr) auto; gap: .85rem; align-items: start; }
|
||||
.ha-icon { display: grid; place-items: center; width: 2.9rem; height: 2.9rem; color: var(--ha-tier); }
|
||||
.ha-lucide { width: 1.78rem; height: 1.78rem; stroke: currentColor; stroke-width: 2.15; filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ha-tier) 24%, transparent)); }
|
||||
.ha-card-title { font-weight: 780; line-height: 1.05; letter-spacing: -0.025em; }
|
||||
.ha-card-category { margin-top: .28rem; color: var(--color-muted-foreground); font-size: .76rem; }
|
||||
.ha-badges { display: flex; flex-direction: column; align-items: flex-end; gap: .25rem; }
|
||||
.ha-tier-badge, .ha-state-badge { border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .16rem .38rem; font-size: .67rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-description { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; min-height: 2.4em; }
|
||||
.ha-criteria { border: 1px solid color-mix(in srgb, var(--ha-tier) 28%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 5%, transparent); }
|
||||
.ha-criteria summary { cursor: pointer; padding: .5rem .65rem; color: var(--ha-tier); text-transform: uppercase; letter-spacing: .1em; font-size: .66rem; font-family: var(--font-mono, ui-monospace, monospace); user-select: none; }
|
||||
.ha-criteria summary:hover { background: color-mix(in srgb, var(--ha-tier) 8%, transparent); }
|
||||
.ha-criteria p { margin: 0; border-top: 1px solid color-mix(in srgb, var(--ha-tier) 18%, var(--color-border)); padding: .55rem .65rem .65rem; color: color-mix(in srgb, var(--color-foreground) 78%, var(--color-muted-foreground)); font-size: .76rem; line-height: 1.38; }
|
||||
.ha-progress-row { display: flex; align-items: center; gap: .55rem; margin-top: 0; }
|
||||
.ha-progress-track { flex: 1; height: .48rem; border: 1px solid color-mix(in srgb, var(--ha-tier) 34%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ha-tier), color-mix(in srgb, var(--ha-tier) 48%, white)); }
|
||||
.ha-progress-text { min-width: 5.4rem; text-align: right; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-muted-foreground); font-size: .72rem; }
|
||||
.ha-evidence-slot { min-height: 1.65rem; margin-top: auto; display: flex; align-items: flex-end; }
|
||||
.ha-evidence { width: 100%; display: flex; align-items: center; gap: .4rem; color: var(--color-muted-foreground); font-size: .72rem; min-width: 0; }
|
||||
.ha-evidence-label { text-transform: uppercase; letter-spacing: .09em; font-family: var(--font-mono, ui-monospace, monospace); flex: 0 0 auto; }
|
||||
.ha-evidence-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: color-mix(in srgb, var(--color-foreground) 84%, var(--color-muted-foreground)); }
|
||||
.ha-evidence-empty { visibility: hidden; }
|
||||
.ha-latest h2 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||
.ha-latest-row { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.ha-chip { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid var(--ha-tier); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 10%, transparent); padding: .35rem .55rem; font-size: .8rem; }
|
||||
.ha-chip-icon .ha-lucide { width: .95rem; height: .95rem; }
|
||||
.ha-slot { border-style: dashed; }
|
||||
.ha-slot-content { display: flex; gap: .6rem; align-items: center; padding: .65rem .8rem !important; font-size: .82rem; }
|
||||
.ha-slot-star { color: #67e8f9; }
|
||||
.ha-slot-muted { color: var(--color-muted-foreground); margin-left: auto; }
|
||||
.ha-error { border-color: #ef4444; color: #fecaca; }
|
||||
.ha-loading { color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); padding: 2rem; border: 1px dashed var(--color-border); }
|
||||
.ha-guide { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: .75rem; }
|
||||
.ha-guide > div { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 82%, transparent); padding: .85rem 1rem; }
|
||||
.ha-guide strong { display: block; margin-bottom: .45rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .12em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-guide p { margin: 0; color: var(--color-muted-foreground); font-size: .84rem; line-height: 1.45; }
|
||||
.ha-tier-legend { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
|
||||
.ha-tier-step { --ha-tier: var(--color-border); display: inline-flex; align-items: center; gap: .32rem; color: var(--ha-tier); border: 1px solid color-mix(in srgb, var(--ha-tier) 52%, var(--color-border)); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .28rem .45rem; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.ha-tier-step i { width: .55rem; height: .55rem; background: var(--ha-tier); display: inline-block; }
|
||||
.ha-tier-arrow { color: var(--color-muted-foreground); }
|
||||
.ha-state-discovered { opacity: .92; }
|
||||
.ha-state-discovered .ha-card-title { color: color-mix(in srgb, var(--color-foreground) 82%, var(--ha-tier)); }
|
||||
.ha-state-secret { opacity: .5; filter: grayscale(.55); }
|
||||
.ha-state-secret:after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(-45deg, transparent 0 8px, rgba(255,255,255,.035) 8px 10px); }
|
||||
.ha-tier-pending { --ha-tier: color-mix(in srgb, var(--color-muted-foreground) 64%, transparent); }
|
||||
.ha-tier-copper { --ha-tier: #b87333; }
|
||||
.ha-tier-silver { --ha-tier: #c0c7d2; }
|
||||
.ha-tier-gold { --ha-tier: #f2c94c; box-shadow: 0 0 22px rgba(242,201,76,.08); }
|
||||
.ha-tier-diamond { --ha-tier: #67e8f9; box-shadow: 0 0 24px rgba(103,232,249,.1); }
|
||||
.ha-tier-olympian { --ha-tier: #c084fc; box-shadow: 0 0 34px rgba(192,132,252,.18), 0 0 12px rgba(242,201,76,.1); }
|
||||
@media (max-width: 980px) { .ha-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ha-guide { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 800px) { .ha-stats { grid-template-columns: 1fr; } .ha-hero { flex-direction: column; align-items: stretch; } .ha-card-head { grid-template-columns: 3.1rem 1fr; } .ha-badges { grid-column: 1 / -1; align-items: flex-start; flex-direction: row; } }
|
||||
|
||||
.ha-secret-empty-content { padding: 1rem !important; }
|
||||
.ha-secret-empty strong { display: block; margin-bottom: .35rem; }
|
||||
.ha-secret-empty p { margin: 0; color: var(--color-muted-foreground); font-size: .86rem; line-height: 1.45; }
|
||||
.ha-page-loading { animation: ha-fade-in .18s ease-out; }
|
||||
.ha-loading-hero { align-items: center; }
|
||||
.ha-scan-status { position: relative; z-index: 1; display: flex; align-items: center; gap: .8rem; min-width: 18rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; color: var(--color-foreground); }
|
||||
.ha-scan-status strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-scan-status p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); }
|
||||
.ha-scan-pulse { width: .72rem; height: .72rem; flex: 0 0 auto; border-radius: 999px; background: #67e8f9; box-shadow: 0 0 0 0 rgba(103,232,249,.55); animation: ha-pulse 1.35s ease-out infinite; }
|
||||
.ha-skeleton-card { pointer-events: none; }
|
||||
.ha-skeleton { position: relative; overflow: hidden; border-radius: 0; background: color-mix(in srgb, var(--color-muted-foreground) 16%, transparent); }
|
||||
.ha-skeleton:after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.14), transparent); animation: ha-shimmer 1.35s infinite; }
|
||||
.ha-skeleton-stack { display: flex; flex-direction: column; gap: .45rem; padding-top: .15rem; }
|
||||
.ha-skeleton-icon { width: 2.9rem; height: 2.9rem; }
|
||||
.ha-skeleton-title { width: 72%; height: .95rem; }
|
||||
.ha-skeleton-meta { width: 45%; height: .65rem; }
|
||||
.ha-skeleton-badge { width: 4.4rem; height: 1.05rem; }
|
||||
.ha-skeleton-badge-short { width: 3.6rem; }
|
||||
.ha-skeleton-line { height: .78rem; width: 92%; }
|
||||
.ha-skeleton-line-short { width: 68%; }
|
||||
.ha-skeleton-criteria { height: 2.2rem; width: 100%; border: 1px solid color-mix(in srgb, var(--color-muted-foreground) 18%, var(--color-border)); }
|
||||
.ha-skeleton-evidence { width: 58%; height: .8rem; }
|
||||
.ha-skeleton-progress { flex: 1; height: .48rem; }
|
||||
.ha-skeleton-progress-text { width: 4.6rem; height: .75rem; }
|
||||
.ha-skeleton-stat-value { width: 56%; height: 1.35rem; margin-top: .55rem; }
|
||||
.ha-skeleton-stat-hint { width: 76%; height: .7rem; margin-top: .55rem; }
|
||||
.ha-loading-guide p { color: var(--color-muted-foreground); }
|
||||
@keyframes ha-shimmer { 100% { transform: translateX(100%); } }
|
||||
@keyframes ha-pulse { 0% { box-shadow: 0 0 0 0 rgba(103,232,249,.48); } 70% { box-shadow: 0 0 0 .65rem rgba(103,232,249,0); } 100% { box-shadow: 0 0 0 0 rgba(103,232,249,0); } }
|
||||
@keyframes ha-fade-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.ha-loading-hero p, .ha-scan-status p, .ha-loading-guide p { text-transform: none; letter-spacing: normal; }
|
||||
|
||||
/* In-progress scan banner — shown on the main page while the background scan
|
||||
* is still walking through session history, so the user sees continuous
|
||||
* progress (X / Y sessions · Z%) instead of guessing whether anything is
|
||||
* happening. Reuses .ha-scan-pulse + ha-pulse keyframes from the loading page.
|
||||
*/
|
||||
.ha-scan-banner { display: flex; flex-direction: column; gap: .6rem; border: 1px solid color-mix(in srgb, #67e8f9 35%, var(--color-border)); background: color-mix(in srgb, var(--color-card) 78%, transparent); padding: .8rem .95rem; animation: ha-fade-in .18s ease-out; }
|
||||
.ha-scan-banner-head { display: flex; align-items: center; gap: .8rem; }
|
||||
.ha-scan-banner-text strong { display: block; font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
|
||||
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
|
||||
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
|
||||
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "hermes-achievements",
|
||||
"label": "Achievements",
|
||||
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
|
||||
"icon": "Star",
|
||||
"version": "0.3.1",
|
||||
"tab": { "path": "/achievements", "position": "after:analytics" },
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "plugin_api.py"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
# Hermes Achievements Performance Implementation Plan
|
||||
|
||||
Status: Ready for execution after hackathon review window
|
||||
Constraint: Plugin remains frozen until judging is complete
|
||||
Decision: `/overview` and top-banner slots are out of scope and will be removed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Baseline & Safety (no behavior change)
|
||||
|
||||
### Task 0.1: Add perf benchmark script (local)
|
||||
Objective: Repro baseline before/after.
|
||||
|
||||
Acceptance:
|
||||
- Can print endpoint timings for `/achievements` (3 runs each, cold + warm).
|
||||
|
||||
### Task 0.2: Define acceptance thresholds
|
||||
Objective: Lock success criteria now.
|
||||
|
||||
Acceptance:
|
||||
- Documented SLOs:
|
||||
- `/achievements` p95 < 1s (cached)
|
||||
- max active scan jobs = 1
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Remove unused overview/slot surface (highest certainty)
|
||||
|
||||
### Task 1.1: Remove `/overview` backend route
|
||||
Objective: Eliminate duplicate heavy endpoint path.
|
||||
|
||||
Acceptance:
|
||||
- `plugin_api.py` no longer exposes `/overview`.
|
||||
|
||||
### Task 1.2: Remove slot registration and SummarySlot frontend code
|
||||
Objective: Remove cross-tab banner fetch behavior.
|
||||
|
||||
Acceptance:
|
||||
- No `registerSlot(..."sessions:top"...)` or `registerSlot(..."analytics:top"...)`.
|
||||
- No frontend call to `api("/overview")`.
|
||||
|
||||
### Task 1.3: Update plugin manifest
|
||||
Objective: Reflect final UI scope.
|
||||
|
||||
Acceptance:
|
||||
- `manifest.json` removes `slots` declarations.
|
||||
- Tab registration remains intact.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Shared snapshot persistence + single-flight for `/achievements`
|
||||
|
||||
### Task 2.1: Introduce snapshot store abstraction + on-disk persistence
|
||||
Objective: Single source of truth for Achievements data that survives process restarts.
|
||||
|
||||
Acceptance:
|
||||
- One structure contains dataset consumed by `/achievements`.
|
||||
- Repeated requests do not recompute when cache is fresh.
|
||||
- Snapshot persisted at `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`.
|
||||
|
||||
### Task 2.2: Single-flight scan coordinator
|
||||
Objective: Prevent concurrent recomputes.
|
||||
|
||||
Acceptance:
|
||||
- Simultaneous requests result in one compute run.
|
||||
|
||||
### Task 2.3: Refactor `/achievements` to read snapshot
|
||||
Objective: Remove direct repeated compute from request path.
|
||||
|
||||
Acceptance:
|
||||
- `/achievements` does not run independent full recompute per request when cache is valid.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Stale-While-Revalidate
|
||||
|
||||
### Task 3.1: TTL state (`FRESH`/`STALE`)
|
||||
Objective: Serve immediately when stale, refresh in background.
|
||||
|
||||
Acceptance:
|
||||
- Cached response returned quickly even when expired.
|
||||
- Refresh is asynchronous.
|
||||
|
||||
### Task 3.2: Add `scan-status` endpoint (optional)
|
||||
Objective: Let UI/ops inspect scan state.
|
||||
|
||||
Acceptance:
|
||||
- Returns state, last success time, last duration, last error.
|
||||
|
||||
### Task 3.3: Add metadata fields to `/achievements`
|
||||
Objective: Improve transparency.
|
||||
|
||||
Acceptance:
|
||||
- Response includes `generated_at`, `is_stale`, maybe `scan_id`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Incremental Scanning (optional but recommended)
|
||||
|
||||
### Task 4.1: Add per-session checkpoint file
|
||||
Objective: Track session-level changes, not just global scan time.
|
||||
|
||||
Acceptance:
|
||||
- Checkpoint persisted at `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`.
|
||||
- For each session: `session_id`, fingerprint (`updated_at`/message_count/hash), and cached contribution.
|
||||
|
||||
### Task 4.2: Incremental aggregation
|
||||
Objective: Recompute only changed/new sessions and reuse unchanged contributions.
|
||||
|
||||
Acceptance:
|
||||
- Typical refresh time drops materially below full scan.
|
||||
- Aggregate rebuild uses: subtract old contribution + add new contribution for changed sessions.
|
||||
|
||||
### Task 4.3: Full rebuild fallback
|
||||
Objective: Preserve correctness.
|
||||
|
||||
Acceptance:
|
||||
- Manual full rescan always possible.
|
||||
- Schema/version changes invalidate checkpoint and force full rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. Unit tests
|
||||
- Snapshot lifecycle transitions
|
||||
- Dedupe logic under parallel requests
|
||||
- `/achievements` response compatibility
|
||||
|
||||
2. Integration tests
|
||||
- Opening Achievements repeatedly causes <=1 heavy scan while in-flight
|
||||
- `/achievements` warm-cache load is fast
|
||||
- manual rescan updates snapshot and timestamps
|
||||
|
||||
3. Manual benchmarks
|
||||
- Compare pre/post `/achievements` timings with same history dataset
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Release internal branch with Phase 1 (remove overview/slots).
|
||||
2. Validate no UI regression in Achievements tab.
|
||||
3. Add Phase 2 snapshot/dedupe.
|
||||
4. Add Phase 3 stale-while-revalidate + status metadata.
|
||||
5. Optional: incremental scanner.
|
||||
|
||||
Rollback: keep old compute path behind temporary feature flag for one release window.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- Achievements tab remains fully functional (counts, latest, tiers, cards, filters).
|
||||
- No `/overview` endpoint or slot calls remain.
|
||||
- Repeated Achievements loads feel immediate after warm cache.
|
||||
- Metrics/unlocks remain unchanged versus baseline.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Hermes Achievements Implementation Spec (Detailed)
|
||||
|
||||
This document is implementation-facing detail to execute the performance refactor later.
|
||||
|
||||
Decision scope: keep only Achievements tab flow; remove `/overview` + top-banner slot integration.
|
||||
|
||||
---
|
||||
|
||||
## A) Current Behavior Summary
|
||||
|
||||
- `evaluate_all()` performs:
|
||||
- full `scan_sessions()`
|
||||
- `SessionDB.list_sessions_rich(...)`
|
||||
- `db.get_messages(session_id)` for each session
|
||||
- text/tool regex analysis + aggregation + evaluation
|
||||
- `/overview` and `/achievements` both currently call `evaluate_all()` directly.
|
||||
- slot calls (`sessions:top`, `analytics:top`) currently invoke `/overview`.
|
||||
|
||||
Consequence: repeated full recomputes and contention.
|
||||
|
||||
---
|
||||
|
||||
## B) De-scope/Removal Changes
|
||||
|
||||
1. Remove backend route:
|
||||
- `GET /overview`
|
||||
|
||||
2. Remove frontend slot usage:
|
||||
- `SummarySlot` component
|
||||
- `registerSlot("sessions:top")`
|
||||
- `registerSlot("analytics:top")`
|
||||
|
||||
3. Remove manifest slot declarations:
|
||||
- `"slots": ["sessions:top", "analytics:top"]`
|
||||
|
||||
4. Keep:
|
||||
- tab route/page for Achievements
|
||||
- `/achievements` endpoint and full tab rendering
|
||||
|
||||
---
|
||||
|
||||
## C) Target Internal Interfaces
|
||||
|
||||
### 1) `SnapshotStore`
|
||||
Responsibilities:
|
||||
- hold latest computed snapshot in memory
|
||||
- persist/load snapshot from disk
|
||||
- expose age and staleness checks
|
||||
|
||||
Storage path:
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`
|
||||
|
||||
Methods (conceptual):
|
||||
- `get()` -> snapshot | null
|
||||
- `set(snapshot)`
|
||||
- `is_stale(ttl_seconds)`
|
||||
|
||||
### 2) `ScanCoordinator`
|
||||
Responsibilities:
|
||||
- single-flight guard for compute jobs
|
||||
- track scan status
|
||||
|
||||
Methods:
|
||||
- `run_if_needed(force: bool = false)`
|
||||
- `get_status()`
|
||||
|
||||
State fields:
|
||||
- `state`: `idle|running|failed`
|
||||
- `started_at`, `finished_at`
|
||||
- `last_error`
|
||||
- `run_count`
|
||||
|
||||
### 3) `build_snapshot()`
|
||||
Responsibilities:
|
||||
- execute current compute logic once
|
||||
- on first run, perform full scan and materialize per-session contributions
|
||||
- on subsequent runs, process only changed/new sessions via checkpoint fingerprints
|
||||
- produce shape consumed by `/achievements`
|
||||
|
||||
Output:
|
||||
- `achievements`
|
||||
- count fields
|
||||
- optional `scan_meta`
|
||||
|
||||
---
|
||||
|
||||
## D) Endpoint Behavior Matrix (No `/overview`)
|
||||
|
||||
| Endpoint | Cache fresh | Cache stale | No cache | Force rescan |
|
||||
|---|---|---|---|---|
|
||||
| `/achievements` | return cached | return stale + trigger bg refresh | blocking bootstrap scan | n/a |
|
||||
| `/rescan` | trigger refresh | trigger refresh | trigger refresh | yes |
|
||||
| `/scan-status` | status only | status only | status only | status only |
|
||||
|
||||
Notes:
|
||||
- At most one scan run active.
|
||||
- Other callers either await same run or receive stale snapshot according to policy.
|
||||
|
||||
---
|
||||
|
||||
## E) Data Shape (Proposed)
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": 0,
|
||||
"is_stale": false,
|
||||
"scan_meta": {
|
||||
"duration_ms": 0,
|
||||
"sessions_scanned": 0,
|
||||
"messages_scanned": 0,
|
||||
"mode": "full",
|
||||
"error": null
|
||||
},
|
||||
"achievements": [],
|
||||
"unlocked_count": 0,
|
||||
"discovered_count": 0,
|
||||
"secret_count": 0,
|
||||
"total_count": 0,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Compatibility guidance:
|
||||
- Keep existing `/achievements` keys.
|
||||
- Add metadata keys without breaking old callers.
|
||||
|
||||
Checkpoint file (new):
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`
|
||||
|
||||
Suggested checkpoint shape:
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": 0,
|
||||
"sessions": {
|
||||
"<session_id>": {
|
||||
"fingerprint": {
|
||||
"updated_at": 0,
|
||||
"message_count": 0,
|
||||
"hash": "optional"
|
||||
},
|
||||
"contribution": {
|
||||
"metrics": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- fingerprint mismatch => recompute that session contribution only.
|
||||
- unchanged fingerprint => reuse stored contribution.
|
||||
|
||||
---
|
||||
|
||||
## F) Concurrency Contract
|
||||
|
||||
- Any request path that needs fresh data must pass through single-flight coordinator.
|
||||
- If a scan is running:
|
||||
- do not start second scan
|
||||
- either await in-flight run (bounded) or serve stale snapshot immediately
|
||||
- lock scope must include scan start/finish state transitions.
|
||||
|
||||
---
|
||||
|
||||
## G) Error Handling Contract
|
||||
|
||||
- If refresh fails and prior snapshot exists:
|
||||
- return prior snapshot with `is_stale=true` and error metadata
|
||||
- If refresh fails and no prior snapshot:
|
||||
- return explicit error response (current behavior equivalent)
|
||||
- `scan-status` should always return last known state/error.
|
||||
|
||||
---
|
||||
|
||||
## H) Frontend Integration Contract
|
||||
|
||||
- Achievements page:
|
||||
- one fetch on mount to `/achievements`
|
||||
- optional background refresh indicator if stale
|
||||
- no top-banner slot integration
|
||||
- avoid duplicate in-flight calls during fast navigation by cancellation/debounce.
|
||||
|
||||
---
|
||||
|
||||
## I) Validation Checklist
|
||||
|
||||
- [ ] `/overview` route removed
|
||||
- [ ] manifest has no `sessions:top`/`analytics:top` slots
|
||||
- [ ] frontend has no `api("/overview")` calls
|
||||
- [ ] repeated Achievements navigation does not create multiple heavy scans
|
||||
- [ ] average warm load times meet SLOs
|
||||
- [ ] unlock totals match pre-refactor baseline for same history
|
||||
- [ ] no schema regression in `/achievements` response
|
||||
|
||||
---
|
||||
|
||||
## J) Suggested File Placement for Future Work
|
||||
|
||||
- backend changes: `dashboard/plugin_api.py`
|
||||
- optional extraction:
|
||||
- `dashboard/perf_snapshot.py`
|
||||
- `dashboard/perf_scan_coordinator.py`
|
||||
- frontend request hygiene: `dashboard/dist/index.js` (or source if available)
|
||||
- plugin metadata: `dashboard/manifest.json`
|
||||
- persisted runtime files:
|
||||
- `~/.hermes/plugins/hermes-achievements/state.json` (existing unlock state)
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json` (new)
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json` (new)
|
||||
|
||||
---
|
||||
|
||||
## K) Post-Implementation Reporting Template
|
||||
|
||||
Record:
|
||||
- dataset size (sessions/messages/tool calls)
|
||||
- pre/post `/achievements` timings (cold/warm)
|
||||
- whether single-flight dedupe triggered under repeated tab open
|
||||
- any behavioral diffs in unlock counts
|
||||
@@ -0,0 +1,174 @@
|
||||
# Hermes Achievements Performance Spec (Post-Hackathon)
|
||||
|
||||
Status: Draft (no code changes yet)
|
||||
Owner: hermes-achievements plugin
|
||||
Scope: `dashboard/plugin_api.py` + `dashboard/dist/index.js` request behavior
|
||||
Decision: **Drop `/overview` and top-banner slots**; keep only Achievements tab data path.
|
||||
|
||||
---
|
||||
|
||||
## 1) Problem Statement
|
||||
|
||||
Current plugin endpoints `/achievements` and `/overview` both execute a full history recomputation (`evaluate_all()`), which performs a full SessionDB scan each request.
|
||||
|
||||
Observed on this machine/repo:
|
||||
- ~83 sessions
|
||||
- ~7,125 messages
|
||||
- ~3,623 tool calls
|
||||
- `evaluate_all()` ~13–16s per call
|
||||
- `/achievements` ~13–15s per call
|
||||
- `/overview` ~12–15s per call
|
||||
- Overlap between endpoints increases perceived wait.
|
||||
|
||||
Given current product direction, `/overview` and cross-tab top-banner slots are not needed.
|
||||
|
||||
---
|
||||
|
||||
## 2) Goals
|
||||
|
||||
- Keep achievement correctness unchanged.
|
||||
- Keep all Achievements-tab UX/data (unlocked/discovered/secrets/highest/latest/cards).
|
||||
- Remove unused summary path (`/overview`) and slot wiring.
|
||||
- Make Achievements tab faster by avoiding duplicate endpoint pathways.
|
||||
- Ensure at most one heavy scan can run at a time.
|
||||
|
||||
Non-goals (phase 1):
|
||||
- Rewriting achievement rules.
|
||||
- Changing badge semantics/states.
|
||||
|
||||
---
|
||||
|
||||
## 3) Endpoint Semantics (Target)
|
||||
|
||||
### `GET /api/plugins/hermes-achievements/achievements`
|
||||
Single source endpoint for Achievements UI.
|
||||
Returns full payload used by the tab:
|
||||
- `achievements`
|
||||
- `unlocked_count`
|
||||
- `discovered_count`
|
||||
- `secret_count`
|
||||
- `total_count`
|
||||
- `error`
|
||||
|
||||
### `POST /api/plugins/hermes-achievements/rescan` (optional)
|
||||
Manual refresh trigger.
|
||||
Prefer async trigger + immediate status response.
|
||||
|
||||
### `GET /api/plugins/hermes-achievements/scan-status` (optional new)
|
||||
Reports scan state for UX/ops.
|
||||
|
||||
### Removed
|
||||
- `GET /api/plugins/hermes-achievements/overview`
|
||||
|
||||
---
|
||||
|
||||
## 4) UI Scope (Target)
|
||||
|
||||
Keep:
|
||||
- Achievements page/tab (`/achievements` in plugin tab manifest)
|
||||
- All existing Achievements tab stats/cards/filters
|
||||
|
||||
Remove:
|
||||
- Top-banner summary slot components using `sessions:top` and `analytics:top`
|
||||
- Any frontend call path to `/overview`
|
||||
|
||||
---
|
||||
|
||||
## 5) Runtime State Machine (for `/achievements`)
|
||||
|
||||
- `FRESH`: cached snapshot age <= TTL
|
||||
- `STALE`: snapshot exists but expired
|
||||
- `SCANNING`: background recompute running
|
||||
- `FAILED`: last recompute failed, last good snapshot still served
|
||||
|
||||
Rules:
|
||||
1. FRESH -> serve immediately.
|
||||
2. STALE + not scanning -> serve stale snapshot immediately and launch background refresh.
|
||||
3. SCANNING -> do not start another scan; join single-flight in-flight job.
|
||||
4. No snapshot yet -> allow one blocking bootstrap scan.
|
||||
|
||||
---
|
||||
|
||||
## 6) Caching & Invalidation
|
||||
|
||||
### Phase 1
|
||||
- In-memory cache + persisted snapshot file.
|
||||
- TTL: 60–180 seconds (configurable).
|
||||
- Single-flight dedupe for scan requests.
|
||||
- Persist plugin data under:
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`
|
||||
|
||||
### Phase 2
|
||||
- Incremental scan checkpoints with per-session fingerprints.
|
||||
- Persist checkpoint data under:
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`
|
||||
- Checkpoint stores, per session:
|
||||
- `session_id`
|
||||
- fingerprint (`updated_at`, message_count, or hash)
|
||||
- cached per-session contribution used for aggregate recomposition
|
||||
- Scan policy:
|
||||
- First run: full scan and materialize snapshot + checkpoint.
|
||||
- Next runs: process only new/changed sessions, reuse unchanged contributions.
|
||||
- Full rebuild only on:
|
||||
- schema/version change
|
||||
- checkpoint corruption
|
||||
- explicit full rescan
|
||||
|
||||
---
|
||||
|
||||
## 7) Frontend Contract
|
||||
|
||||
- Achievements tab requests `/achievements` once on mount.
|
||||
- No slot-based summary fetches.
|
||||
- If response says `is_stale=true`, UI may display “Updating in background”.
|
||||
- Avoid duplicate mount-triggered calls and cancel stale requests on navigation.
|
||||
|
||||
---
|
||||
|
||||
## 8) SLO Targets
|
||||
|
||||
- `/achievements` p95 < 1s (cached)
|
||||
- Max concurrent heavy scans: 1
|
||||
- Background refresh should not block UI
|
||||
|
||||
---
|
||||
|
||||
## 9) Observability Requirements
|
||||
|
||||
Track:
|
||||
- scan count
|
||||
- scan duration avg/p95
|
||||
- dedupe hit count (joined in-flight scans)
|
||||
- stale-served count
|
||||
- failures + last error
|
||||
|
||||
Expose minimal diagnostics in `/scan-status`.
|
||||
|
||||
---
|
||||
|
||||
## 10) Backward Compatibility
|
||||
|
||||
- Keep `/achievements` response shape backward-compatible.
|
||||
- Removing `/overview` is acceptable because slot UI is intentionally removed.
|
||||
- If temporary compatibility is needed, `/overview` can return static deprecation response for one release.
|
||||
|
||||
---
|
||||
|
||||
## 11) Risks
|
||||
|
||||
- Stale data confusion -> mitigate with `generated_at` and explicit refresh status.
|
||||
- Cache invalidation bugs -> start with conservative TTL + manual rescan.
|
||||
- Concurrency bugs -> protect scan section with lock/single-flight guard.
|
||||
- Session mutation edge cases -> use per-session fingerprint invalidation (not global timestamp only).
|
||||
|
||||
---
|
||||
|
||||
## 12) Persistence Files (Explicit)
|
||||
|
||||
Plugin state directory:
|
||||
- `~/.hermes/plugins/hermes-achievements/`
|
||||
|
||||
Files:
|
||||
- `state.json` (existing): unlock tracking
|
||||
- `scan_snapshot.json` (new): latest materialized achievements payload
|
||||
- `scan_checkpoint.json` (new): per-session fingerprints + contributions for incremental refresh
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,156 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "plugin_api.py"
|
||||
spec = importlib.util.spec_from_file_location("plugin_api", MODULE_PATH)
|
||||
plugin_api = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(plugin_api)
|
||||
|
||||
|
||||
class AchievementEngineTests(unittest.TestCase):
|
||||
def test_tool_call_stats_detect_tool_names_and_errors(self):
|
||||
messages = [
|
||||
{"role": "assistant", "tool_calls": [{"function": {"name": "terminal"}}]},
|
||||
{"role": "tool", "tool_name": "terminal", "content": "Error: port 3000 already in use"},
|
||||
{"role": "assistant", "tool_calls": [{"function": {"name": "web_search"}}]},
|
||||
]
|
||||
|
||||
stats = plugin_api.analyze_messages("s1", "Fix dev server", messages)
|
||||
|
||||
self.assertEqual(stats["tool_call_count"], 2)
|
||||
self.assertEqual(stats["tool_names"], {"terminal", "web_search"})
|
||||
self.assertEqual(stats["error_count"], 1)
|
||||
self.assertIs(stats["port_conflict"], True)
|
||||
|
||||
def test_tiered_achievement_reaches_highest_matching_tier(self):
|
||||
definition = {
|
||||
"id": "let_him_cook",
|
||||
"threshold_metric": "max_tool_calls_in_session",
|
||||
"tiers": [
|
||||
{"name": "Copper", "threshold": 10},
|
||||
{"name": "Silver", "threshold": 25},
|
||||
{"name": "Gold", "threshold": 50},
|
||||
],
|
||||
}
|
||||
aggregate = {"max_tool_calls_in_session": 28}
|
||||
|
||||
result = plugin_api.evaluate_tiered(definition, aggregate)
|
||||
|
||||
self.assertIs(result["unlocked"], True)
|
||||
self.assertEqual(result["tier"], "Silver")
|
||||
self.assertEqual(result["progress"], 28)
|
||||
self.assertEqual(result["next_tier"], "Gold")
|
||||
|
||||
def test_tiered_achievement_can_be_discovered_without_unlocking(self):
|
||||
definition = {
|
||||
"id": "terminal_goblin",
|
||||
"threshold_metric": "total_terminal_calls",
|
||||
"tiers": [{"name": "Copper", "threshold": 50}],
|
||||
}
|
||||
aggregate = {"total_terminal_calls": 12}
|
||||
|
||||
result = plugin_api.evaluate_tiered(definition, aggregate)
|
||||
|
||||
self.assertIs(result["unlocked"], False)
|
||||
self.assertIs(result["discovered"], True)
|
||||
self.assertEqual(result["state"], "discovered")
|
||||
self.assertEqual(result["progress"], 12)
|
||||
self.assertEqual(result["next_threshold"], 50)
|
||||
|
||||
def test_secret_achievement_stays_hidden_without_progress(self):
|
||||
definition = {
|
||||
"id": "permission_denied_any_percent",
|
||||
"name": "Permission Denied Any%",
|
||||
"secret": True,
|
||||
"requirements": [{"metric": "permission_denied_events", "gte": 3}],
|
||||
}
|
||||
aggregate = {"permission_denied_events": 0}
|
||||
|
||||
result = plugin_api.evaluate_requirements(definition, aggregate)
|
||||
display = plugin_api.display_achievement({**definition, **result})
|
||||
|
||||
self.assertEqual(result["state"], "secret")
|
||||
self.assertEqual(display["name"], "???")
|
||||
self.assertNotIn("Permission", display["description"])
|
||||
|
||||
def test_multi_condition_unlock_requires_all_requirements(self):
|
||||
definition = {
|
||||
"id": "full_send",
|
||||
"requirements": [
|
||||
{"metric": "max_terminal_calls_in_session", "gte": 10},
|
||||
{"metric": "max_file_tool_calls_in_session", "gte": 5},
|
||||
{"metric": "max_web_calls_in_session", "gte": 2},
|
||||
],
|
||||
}
|
||||
|
||||
partial = plugin_api.evaluate_requirements(definition, {
|
||||
"max_terminal_calls_in_session": 12,
|
||||
"max_file_tool_calls_in_session": 2,
|
||||
"max_web_calls_in_session": 0,
|
||||
})
|
||||
complete = plugin_api.evaluate_requirements(definition, {
|
||||
"max_terminal_calls_in_session": 12,
|
||||
"max_file_tool_calls_in_session": 6,
|
||||
"max_web_calls_in_session": 2,
|
||||
})
|
||||
|
||||
self.assertEqual(partial["state"], "discovered")
|
||||
self.assertIs(partial["unlocked"], False)
|
||||
self.assertLess(partial["progress_pct"], 100)
|
||||
self.assertEqual(complete["state"], "unlocked")
|
||||
self.assertIs(complete["unlocked"], True)
|
||||
|
||||
def test_catalog_has_60_plus_unique_achievements(self):
|
||||
ids = [achievement["id"] for achievement in plugin_api.ACHIEVEMENTS]
|
||||
self.assertGreaterEqual(len(ids), 60)
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
|
||||
def test_model_provider_metrics_are_aggregated(self):
|
||||
sessions = [
|
||||
{"model_names": {"openai/gpt-5", "anthropic/claude-sonnet-4"}},
|
||||
{"model_names": {"google/gemini-pro", "mistral/large"}},
|
||||
{"model_names": {"qwen/qwen3"}},
|
||||
]
|
||||
|
||||
aggregate = plugin_api.aggregate_stats(sessions)
|
||||
|
||||
self.assertEqual(aggregate["distinct_model_count"], 5)
|
||||
self.assertEqual(aggregate["distinct_provider_count"], 5)
|
||||
result = plugin_api.evaluate_definition(
|
||||
next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "five_model_flight"),
|
||||
aggregate,
|
||||
)
|
||||
self.assertEqual(result["state"], "unlocked")
|
||||
self.assertEqual(result["tier"], "Copper")
|
||||
|
||||
def test_removed_noisy_achievements_are_not_in_catalog(self):
|
||||
ids = {achievement["id"] for achievement in plugin_api.ACHIEVEMENTS}
|
||||
self.assertNotIn("fallback_pilot", ids)
|
||||
self.assertNotIn("browser_sleuth", ids)
|
||||
self.assertNotIn("release_ritualist", ids)
|
||||
|
||||
def test_open_weights_pilgrim_counts_only_local_model_metadata(self):
|
||||
aggregate_mentions_only = plugin_api.aggregate_stats([
|
||||
{"model_names": {"openai/gpt-5"}, "local_model_events": 999},
|
||||
])
|
||||
aggregate_local_chat = plugin_api.aggregate_stats([
|
||||
{"model_names": {"openai/gpt-5"}},
|
||||
{"model_names": {"ollama/llama3"}},
|
||||
])
|
||||
definition = next(a for a in plugin_api.ACHIEVEMENTS if a["id"] == "open_weights_pilgrim")
|
||||
|
||||
self.assertEqual(aggregate_mentions_only["local_model_chat_sessions"], 0)
|
||||
self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_mentions_only)["state"], "discovered")
|
||||
self.assertEqual(aggregate_local_chat["local_model_chat_sessions"], 1)
|
||||
self.assertEqual(plugin_api.evaluate_definition(definition, aggregate_local_chat)["state"], "unlocked")
|
||||
|
||||
def test_config_surgeon_ignores_generic_config_mentions(self):
|
||||
stats = plugin_api.analyze_messages("s1", "Config talk", [{"content": "config config configuration not configured"}])
|
||||
self.assertEqual(stats["config_events"], 0)
|
||||
stats = plugin_api.analyze_messages("s2", "Real config", [{"content": "edited config.yaml, manifest.json, and .env.local"}])
|
||||
self.assertGreaterEqual(stats["config_events"], 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -528,6 +528,46 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
|
||||
# -- Tool implementations ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_result(resp: Any) -> Any:
|
||||
"""Return OpenViking payload body regardless of wrapped/unwrapped shape."""
|
||||
if isinstance(resp, dict) and "result" in resp:
|
||||
return resp.get("result")
|
||||
return resp
|
||||
|
||||
@staticmethod
|
||||
def _normalize_summary_uri(uri: str) -> str:
|
||||
"""Map pseudo summary files to their parent directory URI for L0/L1 reads."""
|
||||
if not uri:
|
||||
return uri
|
||||
for suffix in ("/.abstract.md", "/.overview.md", "/.read.md", "/.full.md"):
|
||||
if uri.endswith(suffix):
|
||||
return uri[: -len(suffix)] or "viking://"
|
||||
return uri
|
||||
|
||||
def _is_directory_uri(self, uri: str) -> bool | None:
|
||||
"""Probe fs/stat to decide if a URI is a directory.
|
||||
|
||||
Returns True/False when the server answers cleanly, and None when the
|
||||
probe itself fails (network error, unexpected shape). Callers should
|
||||
treat None as "unknown" and fall back to the exception-based path.
|
||||
"""
|
||||
try:
|
||||
resp = self._client.get("/api/v1/fs/stat", params={"uri": uri})
|
||||
except Exception:
|
||||
return None
|
||||
result = self._unwrap_result(resp)
|
||||
if isinstance(result, dict):
|
||||
if "isDir" in result:
|
||||
return bool(result.get("isDir"))
|
||||
if "is_dir" in result:
|
||||
return bool(result.get("is_dir"))
|
||||
if result.get("type") == "dir":
|
||||
return True
|
||||
if result.get("type") == "file":
|
||||
return False
|
||||
return None
|
||||
|
||||
def _tool_search(self, args: dict) -> str:
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
@@ -576,27 +616,72 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
return tool_error("uri is required")
|
||||
|
||||
level = args.get("level", "overview")
|
||||
# Map our level names to OpenViking GET endpoints
|
||||
if level == "abstract":
|
||||
resp = self._client.get("/api/v1/content/abstract", params={"uri": uri})
|
||||
elif level == "full":
|
||||
|
||||
summary_level = level in ("abstract", "overview")
|
||||
# OpenViking expects directory URIs for pseudo summary files
|
||||
# (e.g. viking://user/hermes/.overview.md).
|
||||
resolved_uri = self._normalize_summary_uri(uri) if summary_level else uri
|
||||
used_fallback = False
|
||||
|
||||
# abstract/overview endpoints are directory-only on OpenViking
|
||||
# (v0.3.x returns 500/412 for file URIs). When the caller asks for a
|
||||
# summary level on a non-pseudo URI, probe fs/stat first and route
|
||||
# file URIs straight to /content/read instead of eating a failing
|
||||
# round-trip. The pseudo-URI path already points at a directory, so
|
||||
# skip the probe there.
|
||||
if summary_level and resolved_uri == uri:
|
||||
is_dir = self._is_directory_uri(uri)
|
||||
if is_dir is False:
|
||||
resolved_uri = uri
|
||||
used_fallback = True
|
||||
|
||||
# Map our level names to OpenViking GET endpoints.
|
||||
endpoint = "/api/v1/content/read"
|
||||
if not used_fallback:
|
||||
if level == "abstract":
|
||||
endpoint = "/api/v1/content/abstract"
|
||||
elif level == "overview":
|
||||
endpoint = "/api/v1/content/overview"
|
||||
|
||||
try:
|
||||
resp = self._client.get(endpoint, params={"uri": resolved_uri})
|
||||
except Exception:
|
||||
# OpenViking may return HTTP 500 for abstract/overview reads on normal
|
||||
# file URIs (mem_*.md). For those, gracefully fallback to full read.
|
||||
if not summary_level or resolved_uri != uri or used_fallback:
|
||||
raise
|
||||
resp = self._client.get("/api/v1/content/read", params={"uri": uri})
|
||||
else: # overview
|
||||
resp = self._client.get("/api/v1/content/overview", params={"uri": uri})
|
||||
used_fallback = True
|
||||
|
||||
result = resp.get("result", "")
|
||||
# result is a plain string from the content endpoints
|
||||
content = result if isinstance(result, str) else result.get("content", "")
|
||||
result = self._unwrap_result(resp)
|
||||
# Content endpoints may return either plain strings or objects.
|
||||
if isinstance(result, str):
|
||||
content = result
|
||||
elif isinstance(result, dict):
|
||||
content = result.get("content", "") or result.get("text", "")
|
||||
else:
|
||||
content = ""
|
||||
|
||||
# Truncate very long content to avoid flooding the context
|
||||
if len(content) > 8000:
|
||||
content = content[:8000] + "\n\n[... truncated, use a more specific URI or abstract level]"
|
||||
# Truncate long content to avoid flooding context.
|
||||
max_len = 8000
|
||||
if level == "overview":
|
||||
max_len = 4000
|
||||
elif level == "abstract":
|
||||
max_len = 1200
|
||||
|
||||
return json.dumps({
|
||||
if len(content) > max_len:
|
||||
content = content[:max_len] + "\n\n[... truncated, use a more specific URI or full level]"
|
||||
|
||||
payload = {
|
||||
"uri": uri,
|
||||
"resolved_uri": resolved_uri,
|
||||
"level": level,
|
||||
"content": content,
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
if used_fallback:
|
||||
payload["fallback"] = "content/read"
|
||||
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def _tool_browse(self, args: dict) -> str:
|
||||
action = args.get("action", "list")
|
||||
@@ -606,19 +691,27 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
endpoint_map = {"tree": "/api/v1/fs/tree", "list": "/api/v1/fs/ls", "stat": "/api/v1/fs/stat"}
|
||||
endpoint = endpoint_map.get(action, "/api/v1/fs/ls")
|
||||
resp = self._client.get(endpoint, params={"uri": path})
|
||||
result = resp.get("result", {})
|
||||
result = self._unwrap_result(resp)
|
||||
|
||||
# Format list/tree results for readability
|
||||
if action in ("list", "tree") and isinstance(result, list):
|
||||
entries = []
|
||||
for e in result[:50]: # cap at 50 entries
|
||||
entries.append({
|
||||
"name": e.get("rel_path", e.get("name", "")),
|
||||
"uri": e.get("uri", ""),
|
||||
"type": "dir" if e.get("isDir") else "file",
|
||||
"abstract": e.get("abstract", ""),
|
||||
})
|
||||
return json.dumps({"path": path, "entries": entries}, ensure_ascii=False)
|
||||
if action in ("list", "tree"):
|
||||
raw_entries = result
|
||||
if isinstance(result, dict):
|
||||
raw_entries = result.get("entries") or result.get("items") or result.get("children") or []
|
||||
|
||||
if isinstance(raw_entries, list):
|
||||
entries = []
|
||||
for e in raw_entries[:50]: # cap at 50 entries
|
||||
uri = e.get("uri", "")
|
||||
name = e.get("rel_path") or e.get("name") or (uri.rsplit("/", 1)[-1] if uri else "")
|
||||
is_dir = bool(e.get("isDir") or e.get("is_dir") or e.get("type") == "dir")
|
||||
entries.append({
|
||||
"name": name,
|
||||
"uri": uri,
|
||||
"type": "dir" if is_dir else "file",
|
||||
"abstract": e.get("abstract", ""),
|
||||
})
|
||||
return json.dumps({"path": path, "entries": entries}, ensure_ascii=False)
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,686 @@
|
||||
"""
|
||||
IRC Platform Adapter for Hermes Agent.
|
||||
|
||||
A plugin-based gateway adapter that connects to an IRC server and relays
|
||||
messages to/from the Hermes agent. Zero external dependencies — uses
|
||||
Python's stdlib asyncio for the IRC protocol.
|
||||
|
||||
Configuration in config.yaml::
|
||||
|
||||
gateway:
|
||||
platforms:
|
||||
irc:
|
||||
enabled: true
|
||||
extra:
|
||||
server: irc.libera.chat
|
||||
port: 6697
|
||||
nickname: hermes-bot
|
||||
channel: "#hermes"
|
||||
use_tls: true
|
||||
server_password: "" # optional server password
|
||||
nickserv_password: "" # optional NickServ identification
|
||||
allowed_users: [] # empty = allow all, or list of nicks
|
||||
max_message_length: 450 # IRC line limit (safe default)
|
||||
|
||||
Or via environment variables (overrides config.yaml):
|
||||
IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS,
|
||||
IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy import: BasePlatformAdapter and friends live in the main repo.
|
||||
# We import at function/class level to avoid import errors when the plugin
|
||||
# is discovered but the gateway hasn't been fully initialised yet.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
)
|
||||
from gateway.session import SessionSource
|
||||
from gateway.config import PlatformConfig, Platform
|
||||
|
||||
|
||||
def _ensure_imports():
|
||||
"""No-op — kept for backward compatibility with any call sites."""
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IRC protocol helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_irc_message(raw: str) -> dict:
|
||||
"""Parse a raw IRC protocol line into components.
|
||||
|
||||
Returns dict with keys: prefix, command, params.
|
||||
"""
|
||||
prefix = ""
|
||||
trailing = ""
|
||||
|
||||
if raw.startswith(":"):
|
||||
try:
|
||||
prefix, raw = raw[1:].split(" ", 1)
|
||||
except ValueError:
|
||||
prefix = raw[1:]
|
||||
raw = ""
|
||||
|
||||
if " :" in raw:
|
||||
raw, trailing = raw.split(" :", 1)
|
||||
|
||||
parts = raw.split()
|
||||
command = parts[0] if parts else ""
|
||||
params = parts[1:] if len(parts) > 1 else []
|
||||
if trailing:
|
||||
params.append(trailing)
|
||||
|
||||
return {"prefix": prefix, "command": command, "params": params}
|
||||
|
||||
|
||||
def _extract_nick(prefix: str) -> str:
|
||||
"""Extract nickname from IRC prefix (nick!user@host)."""
|
||||
return prefix.split("!")[0] if "!" in prefix else prefix
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IRC Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IRCAdapter(BasePlatformAdapter):
|
||||
"""Async IRC adapter implementing the BasePlatformAdapter interface.
|
||||
|
||||
This class is instantiated by the adapter_factory passed to
|
||||
register_platform().
|
||||
"""
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
platform = Platform("irc")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
|
||||
# Connection settings (env vars override config.yaml)
|
||||
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
|
||||
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
|
||||
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
self.use_tls = (
|
||||
os.getenv("IRC_USE_TLS", "").lower() in ("1", "true", "yes")
|
||||
if os.getenv("IRC_USE_TLS")
|
||||
else extra.get("use_tls", True)
|
||||
)
|
||||
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
|
||||
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
|
||||
|
||||
# Auth
|
||||
self.allowed_users: list = extra.get("allowed_users", [])
|
||||
# IRC nicks are case-insensitive — normalise for lookups
|
||||
self._allowed_users_lower: set = {u.lower() for u in self.allowed_users if isinstance(u, str)}
|
||||
|
||||
# IRC limits
|
||||
max_msg = extra.get("max_message_length")
|
||||
if max_msg is None:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get("irc")
|
||||
if entry and entry.max_message_length:
|
||||
max_msg = entry.max_message_length
|
||||
except Exception:
|
||||
pass
|
||||
self.max_message_length = int(max_msg or 450)
|
||||
|
||||
# Runtime state
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._recv_task: Optional[asyncio.Task] = None
|
||||
self._current_nick = self.nickname
|
||||
self._registered = False # IRC registration complete
|
||||
self._registration_event = asyncio.Event()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "IRC"
|
||||
|
||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the IRC server, register, and join the channel."""
|
||||
if not self.server or not self.channel:
|
||||
logger.error("IRC: server and channel must be configured")
|
||||
self._set_fatal_error(
|
||||
"config_missing",
|
||||
"IRC_SERVER and IRC_CHANNEL must be set",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
|
||||
# Prevent two profiles from using the same IRC identity
|
||||
try:
|
||||
from gateway.status import acquire_scoped_lock, release_scoped_lock
|
||||
lock_key = f"{self.server}:{self.nickname}"
|
||||
if not acquire_scoped_lock("irc", lock_key):
|
||||
logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server)
|
||||
self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False)
|
||||
return False
|
||||
self._lock_key = lock_key
|
||||
except ImportError:
|
||||
self._lock_key = None # status module not available (e.g. tests)
|
||||
|
||||
try:
|
||||
ssl_ctx = None
|
||||
if self.use_tls:
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.server, self.port, ssl=ssl_ctx),
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("IRC: failed to connect to %s:%s — %s", self.server, self.port, e)
|
||||
self._set_fatal_error("connect_failed", str(e), retryable=True)
|
||||
return False
|
||||
|
||||
# IRC registration sequence
|
||||
if self.server_password:
|
||||
await self._send_raw(f"PASS {self.server_password}")
|
||||
await self._send_raw(f"NICK {self.nickname}")
|
||||
await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent")
|
||||
|
||||
# Start receive loop
|
||||
self._recv_task = asyncio.create_task(self._receive_loop())
|
||||
|
||||
# Wait for registration (001 RPL_WELCOME) with timeout
|
||||
try:
|
||||
await asyncio.wait_for(self._registration_event.wait(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("IRC: registration timed out")
|
||||
await self.disconnect()
|
||||
self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True)
|
||||
return False
|
||||
|
||||
# NickServ identification
|
||||
if self.nickserv_password:
|
||||
await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}")
|
||||
await asyncio.sleep(2) # Give NickServ time to process
|
||||
|
||||
# Join channel
|
||||
await self._send_raw(f"JOIN {self.channel}")
|
||||
|
||||
self._mark_connected()
|
||||
logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Quit and close the connection."""
|
||||
# Release the scoped lock so another profile can use this identity
|
||||
if getattr(self, "_lock_key", None):
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
release_scoped_lock("irc", self._lock_key)
|
||||
except Exception:
|
||||
pass
|
||||
self._mark_disconnected()
|
||||
if self._writer and not self._writer.is_closing():
|
||||
try:
|
||||
await self._send_raw("QUIT :Hermes Agent shutting down")
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._recv_task and not self._recv_task.done():
|
||||
self._recv_task.cancel()
|
||||
try:
|
||||
await self._recv_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
self._registered = False
|
||||
self._registration_event.clear()
|
||||
|
||||
# ── Sending ───────────────────────────────────────────────────────────
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not self._writer or self._writer.is_closing():
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
target = chat_id # channel name or nick for DMs
|
||||
lines = self._split_message(content, target)
|
||||
|
||||
for line in lines:
|
||||
try:
|
||||
await self._send_raw(f"PRIVMSG {target} :{line}")
|
||||
# Basic rate limiting to avoid excess flood
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
return SendResult(success=True, message_id=str(int(time.time() * 1000)))
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""IRC has no typing indicator — no-op."""
|
||||
pass
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
is_channel = chat_id.startswith("#") or chat_id.startswith("&")
|
||||
return {
|
||||
"name": chat_id,
|
||||
"type": "group" if is_channel else "dm",
|
||||
}
|
||||
|
||||
# ── Message splitting ─────────────────────────────────────────────────
|
||||
|
||||
def _split_message(self, content: str, target: str) -> List[str]:
|
||||
"""Split a long message into IRC-safe chunks.
|
||||
|
||||
IRC has a ~512 byte line limit. After accounting for protocol
|
||||
overhead (``PRIVMSG <target> :``), we split content into chunks.
|
||||
"""
|
||||
# Strip markdown formatting that doesn't render in IRC
|
||||
content = self._strip_markdown(content)
|
||||
|
||||
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2 # +2 for \r\n
|
||||
max_bytes = 510 - overhead
|
||||
user_limit = self.max_message_length
|
||||
|
||||
lines: List[str] = []
|
||||
for paragraph in content.split("\n"):
|
||||
if not paragraph.strip():
|
||||
continue
|
||||
while True:
|
||||
para_bytes = paragraph.encode("utf-8")
|
||||
limit = min(user_limit, max_bytes)
|
||||
if len(para_bytes) <= limit:
|
||||
if paragraph.strip():
|
||||
lines.append(paragraph)
|
||||
break
|
||||
# Binary search for a safe character boundary <= limit
|
||||
low, high = 1, len(paragraph)
|
||||
best = 0
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
if len(paragraph[:mid].encode("utf-8")) <= limit:
|
||||
best = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
split_at = best
|
||||
# Prefer a space boundary
|
||||
space = paragraph.rfind(" ", 0, split_at)
|
||||
if space > split_at // 3:
|
||||
split_at = space
|
||||
lines.append(paragraph[:split_at].rstrip())
|
||||
paragraph = paragraph[split_at:].lstrip()
|
||||
|
||||
return lines if lines else [""]
|
||||
|
||||
@staticmethod
|
||||
def _strip_markdown(text: str) -> str:
|
||||
"""Convert basic markdown to plain text for IRC."""
|
||||
# Bold: **text** or __text__ → text
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
# Italic: *text* or _text_ → text
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
||||
# Inline code: `text` → text
|
||||
text = re.sub(r"`(.+?)`", r"\1", text)
|
||||
# Code blocks: ```...``` → content
|
||||
text = re.sub(r"```\w*\n?", "", text)
|
||||
# Images:  → url (must come BEFORE links)
|
||||
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", text)
|
||||
# Links: [text](url) → text (url)
|
||||
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
|
||||
return text
|
||||
|
||||
# ── Raw IRC I/O ──────────────────────────────────────────────────────
|
||||
|
||||
async def _send_raw(self, line: str) -> None:
|
||||
"""Send a raw IRC protocol line."""
|
||||
if not self._writer or self._writer.is_closing():
|
||||
return
|
||||
encoded = (line + "\r\n").encode("utf-8")
|
||||
self._writer.write(encoded)
|
||||
await self._writer.drain()
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
"""Main receive loop — reads lines and dispatches them."""
|
||||
buffer = b""
|
||||
try:
|
||||
while self._reader and not self._reader.at_eof():
|
||||
data = await self._reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
while b"\r\n" in buffer:
|
||||
line, buffer = buffer.split(b"\r\n", 1)
|
||||
try:
|
||||
decoded = line.decode("utf-8", errors="replace")
|
||||
await self._handle_line(decoded)
|
||||
except Exception as e:
|
||||
logger.warning("IRC: error handling line: %s", e)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("IRC: receive loop error: %s", e)
|
||||
finally:
|
||||
if self.is_connected:
|
||||
logger.warning("IRC: connection lost, marking disconnected")
|
||||
self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True)
|
||||
await self._notify_fatal_error()
|
||||
|
||||
async def _handle_line(self, raw: str) -> None:
|
||||
"""Dispatch a single IRC protocol line."""
|
||||
msg = _parse_irc_message(raw)
|
||||
command = msg["command"]
|
||||
params = msg["params"]
|
||||
|
||||
# PING/PONG keepalive
|
||||
if command == "PING":
|
||||
payload = params[0] if params else ""
|
||||
await self._send_raw(f"PONG :{payload}")
|
||||
return
|
||||
|
||||
# RPL_WELCOME (001) — registration complete
|
||||
if command == "001":
|
||||
self._registered = True
|
||||
self._registration_event.set()
|
||||
if params:
|
||||
# Server may confirm our nick in the first param
|
||||
self._current_nick = params[0]
|
||||
return
|
||||
|
||||
# ERR_NICKNAMEINUSE (433) — nick collision during registration
|
||||
if command == "433":
|
||||
# Retry with incrementing suffix: hermes_, hermes_1, hermes_2...
|
||||
base = self.nickname.rstrip("_0123456789")
|
||||
suffix_match = re.search(r"_(\d+)$", self._current_nick)
|
||||
if suffix_match:
|
||||
next_num = int(suffix_match.group(1)) + 1
|
||||
self._current_nick = f"{base}_{next_num}"
|
||||
elif self._current_nick == self.nickname:
|
||||
self._current_nick = self.nickname + "_"
|
||||
else:
|
||||
self._current_nick = self.nickname + "_1"
|
||||
await self._send_raw(f"NICK {self._current_nick}")
|
||||
return
|
||||
|
||||
# PRIVMSG — incoming message (channel or DM)
|
||||
if command == "PRIVMSG" and len(params) >= 2:
|
||||
sender_nick = _extract_nick(msg["prefix"])
|
||||
target = params[0]
|
||||
text = params[1]
|
||||
|
||||
# Ignore our own messages
|
||||
if sender_nick.lower() == self._current_nick.lower():
|
||||
return
|
||||
|
||||
# CTCP ACTION (/me) — convert to text
|
||||
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
|
||||
text = f"* {sender_nick} {text[8:-1]}"
|
||||
|
||||
# Ignore other CTCP
|
||||
if text.startswith("\x01"):
|
||||
return
|
||||
|
||||
# Determine if this is a channel message or DM
|
||||
is_channel = target.startswith("#") or target.startswith("&")
|
||||
chat_id = target if is_channel else sender_nick
|
||||
chat_type = "group" if is_channel else "dm"
|
||||
|
||||
# In channels, only respond if addressed (nick: or nick,)
|
||||
if is_channel:
|
||||
addressed = False
|
||||
for prefix in (f"{self._current_nick}:", f"{self._current_nick},",
|
||||
f"{self._current_nick} "):
|
||||
if text.lower().startswith(prefix.lower()):
|
||||
text = text[len(prefix):].strip()
|
||||
addressed = True
|
||||
break
|
||||
if not addressed:
|
||||
return # Ignore unaddressed channel messages
|
||||
|
||||
# Auth check (case-insensitive)
|
||||
if self._allowed_users_lower and sender_nick.lower() not in self._allowed_users_lower:
|
||||
logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick)
|
||||
return
|
||||
|
||||
await self._dispatch_message(
|
||||
text=text,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id=sender_nick,
|
||||
user_name=sender_nick,
|
||||
)
|
||||
|
||||
# NICK — track our own nick changes
|
||||
if command == "NICK" and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower():
|
||||
if params:
|
||||
self._current_nick = params[0]
|
||||
|
||||
async def _dispatch_message(
|
||||
self,
|
||||
text: str,
|
||||
chat_id: str,
|
||||
chat_type: str,
|
||||
user_id: str,
|
||||
user_name: str,
|
||||
) -> None:
|
||||
"""Build a MessageEvent and hand it to the base class handler."""
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=str(int(time.time() * 1000)),
|
||||
timestamp=__import__("datetime").datetime.now(),
|
||||
)
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_requirements() -> bool:
|
||||
"""Check if IRC is configured.
|
||||
|
||||
Only requires the server and channel — no external pip packages needed.
|
||||
"""
|
||||
server = os.getenv("IRC_SERVER", "")
|
||||
channel = os.getenv("IRC_CHANNEL", "")
|
||||
# Also accept config.yaml-only configuration (no env vars).
|
||||
# The gateway passes PlatformConfig; we just check env for the
|
||||
# hermes setup / requirements check path.
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Validate that the platform config has enough info to connect."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Interactive `hermes gateway setup` flow for the IRC platform.
|
||||
|
||||
Lazy-imports ``hermes_cli.setup`` helpers so the plugin stays importable
|
||||
in non-CLI contexts (gateway runtime, tests).
|
||||
"""
|
||||
from hermes_cli.setup import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
save_env_value,
|
||||
get_env_value,
|
||||
print_header,
|
||||
print_info,
|
||||
print_warning,
|
||||
print_success,
|
||||
)
|
||||
|
||||
print_header("IRC")
|
||||
existing_server = get_env_value("IRC_SERVER")
|
||||
if existing_server:
|
||||
print_info(f"IRC: already configured (server: {existing_server})")
|
||||
if not prompt_yes_no("Reconfigure IRC?", False):
|
||||
return
|
||||
|
||||
print_info("Connect Hermes to an IRC network. Uses Python stdlib — no extra packages needed.")
|
||||
print_info(" Works with Libera.Chat, OFTC, your own ZNC/InspIRCd, etc.")
|
||||
print()
|
||||
|
||||
server = prompt("IRC server hostname (e.g. irc.libera.chat)", default=existing_server or "")
|
||||
if not server:
|
||||
print_warning("Server is required — skipping IRC setup")
|
||||
return
|
||||
save_env_value("IRC_SERVER", server.strip())
|
||||
|
||||
use_tls = prompt_yes_no("Use TLS (recommended)?", True)
|
||||
save_env_value("IRC_USE_TLS", "true" if use_tls else "false")
|
||||
|
||||
default_port = "6697" if use_tls else "6667"
|
||||
port = prompt(f"Port (default {default_port})", default=get_env_value("IRC_PORT") or "")
|
||||
if port:
|
||||
try:
|
||||
save_env_value("IRC_PORT", str(int(port)))
|
||||
except ValueError:
|
||||
print_warning(f"Invalid port — using default {default_port}")
|
||||
elif get_env_value("IRC_PORT"):
|
||||
# User cleared the prompt; drop the override so the default applies.
|
||||
save_env_value("IRC_PORT", "")
|
||||
|
||||
nickname = prompt(
|
||||
"Bot nickname (e.g. hermes-bot)",
|
||||
default=get_env_value("IRC_NICKNAME") or "",
|
||||
)
|
||||
if not nickname:
|
||||
print_warning("Nickname is required — skipping IRC setup")
|
||||
return
|
||||
save_env_value("IRC_NICKNAME", nickname.strip())
|
||||
|
||||
channel = prompt(
|
||||
"Channel to join (e.g. #hermes — comma-separate for multiple)",
|
||||
default=get_env_value("IRC_CHANNEL") or "",
|
||||
)
|
||||
if not channel:
|
||||
print_warning("Channel is required — skipping IRC setup")
|
||||
return
|
||||
save_env_value("IRC_CHANNEL", channel.strip())
|
||||
|
||||
print()
|
||||
print_info("🔑 Optional authentication")
|
||||
print_info(" Leave blank to skip.")
|
||||
if prompt_yes_no("Configure a server password (PASS command)?", False):
|
||||
server_password = prompt("Server password", password=True)
|
||||
if server_password:
|
||||
save_env_value("IRC_SERVER_PASSWORD", server_password)
|
||||
|
||||
if prompt_yes_no("Identify with NickServ on connect?", False):
|
||||
nickserv = prompt("NickServ password", password=True)
|
||||
if nickserv:
|
||||
save_env_value("IRC_NICKSERV_PASSWORD", nickserv)
|
||||
|
||||
print()
|
||||
print_info("🔒 Access control: restrict who can message the bot")
|
||||
print_info(" IRC nicks are not authenticated — anyone can claim any nick.")
|
||||
print_info(" For public channels, pair with NickServ-only mode on your network")
|
||||
print_info(" if you want stronger identity guarantees.")
|
||||
allow_all = prompt_yes_no("Allow all users in the channel to talk to the bot?", False)
|
||||
if allow_all:
|
||||
save_env_value("IRC_ALLOW_ALL_USERS", "true")
|
||||
save_env_value("IRC_ALLOWED_USERS", "")
|
||||
print_warning("⚠️ Open access — any nick in the channel can command the bot.")
|
||||
else:
|
||||
save_env_value("IRC_ALLOW_ALL_USERS", "false")
|
||||
allowed = prompt(
|
||||
"Allowed nicks (comma-separated, leave empty to deny everyone)",
|
||||
default=get_env_value("IRC_ALLOWED_USERS") or "",
|
||||
)
|
||||
if allowed:
|
||||
save_env_value("IRC_ALLOWED_USERS", allowed.replace(" ", ""))
|
||||
print_success("Allowlist configured")
|
||||
else:
|
||||
save_env_value("IRC_ALLOWED_USERS", "")
|
||||
print_info("No nicks allowed — the bot will ignore all messages until you add nicks.")
|
||||
|
||||
print()
|
||||
print_success("IRC configuration saved to ~/.hermes/.env")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
|
||||
def is_connected(config) -> bool:
|
||||
"""Check whether IRC is configured (env or config.yaml)."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
||||
install_hint="No extra packages needed (stdlib only)",
|
||||
setup_fn=interactive_setup,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="IRC_ALLOWED_USERS",
|
||||
allow_all_env="IRC_ALLOW_ALL_USERS",
|
||||
# IRC line limit after protocol overhead
|
||||
max_message_length=450,
|
||||
# Display
|
||||
emoji="💬",
|
||||
# IRC doesn't have phone numbers to redact
|
||||
pii_safe=False,
|
||||
allow_update_command=True,
|
||||
# LLM guidance
|
||||
platform_hint=(
|
||||
"You are chatting via IRC. IRC does not support markdown formatting "
|
||||
"— use plain text only. Messages are limited to ~450 characters per "
|
||||
"line (long messages are automatically split). In channels, users "
|
||||
"address you by prefixing your nick. Keep responses concise and "
|
||||
"conversational."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
name: irc-platform
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
IRC gateway adapter for Hermes Agent.
|
||||
Connects to an IRC server and relays messages between an IRC channel
|
||||
(or DMs) and the Hermes agent. No external dependencies — uses
|
||||
Python's stdlib asyncio for the IRC protocol.
|
||||
author: Nous Research
|
||||
requires_env:
|
||||
- IRC_SERVER
|
||||
- IRC_CHANNEL
|
||||
- IRC_NICKNAME
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
Microsoft Teams platform adapter for Hermes Agent.
|
||||
|
||||
Uses the microsoft-teams-apps SDK for authentication and activity processing.
|
||||
Runs an aiohttp webhook server to receive messages from Teams.
|
||||
Proactive messaging (send, typing) uses the SDK's App.send() method.
|
||||
|
||||
Requires:
|
||||
pip install microsoft-teams-apps aiohttp
|
||||
TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID env vars
|
||||
|
||||
Configuration in config.yaml:
|
||||
platforms:
|
||||
teams:
|
||||
enabled: true
|
||||
extra:
|
||||
client_id: "your-client-id" # or TEAMS_CLIENT_ID env var
|
||||
client_secret: "your-secret" # or TEAMS_CLIENT_SECRET env var
|
||||
tenant_id: "your-tenant-id" # or TEAMS_TENANT_ID env var
|
||||
port: 3978 # or TEAMS_PORT env var
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOHTTP_AVAILABLE = False
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
from microsoft_teams.apps import App, ActivityContext
|
||||
from microsoft_teams.api import MessageActivity, ConversationReference
|
||||
from microsoft_teams.api.activities.typing import TypingActivityInput
|
||||
from microsoft_teams.api.activities.invoke.adaptive_card import AdaptiveCardInvokeActivity
|
||||
from microsoft_teams.api.models.adaptive_card import (
|
||||
AdaptiveCardActionCardResponse,
|
||||
AdaptiveCardActionMessageResponse,
|
||||
)
|
||||
from microsoft_teams.api.models.invoke_response import InvokeResponse, AdaptiveCardInvokeResponse
|
||||
from microsoft_teams.apps.http.adapter import (
|
||||
HttpMethod,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpRouteHandler,
|
||||
)
|
||||
from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock
|
||||
|
||||
TEAMS_SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
TEAMS_SDK_AVAILABLE = False
|
||||
App = None # type: ignore[assignment,misc]
|
||||
ActivityContext = None # type: ignore[assignment,misc]
|
||||
MessageActivity = None # type: ignore[assignment,misc]
|
||||
ConversationReference = None # type: ignore[assignment,misc]
|
||||
TypingActivityInput = None # type: ignore[assignment,misc]
|
||||
AdaptiveCardInvokeActivity = None # type: ignore[assignment,misc]
|
||||
AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc]
|
||||
AdaptiveCardActionMessageResponse = None # type: ignore[assignment,misc]
|
||||
AdaptiveCardInvokeResponse = None # type: ignore[assignment,misc,union-attr]
|
||||
InvokeResponse = None # type: ignore[assignment,misc]
|
||||
HttpMethod = str # type: ignore[assignment,misc]
|
||||
HttpRequest = None # type: ignore[assignment,misc]
|
||||
HttpResponse = None # type: ignore[assignment,misc]
|
||||
HttpRouteHandler = None # type: ignore[assignment,misc]
|
||||
AdaptiveCard = None # type: ignore[assignment,misc]
|
||||
ExecuteAction = None # type: ignore[assignment,misc]
|
||||
TextBlock = None # type: ignore[assignment,misc]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
cache_image_from_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_PORT = 3978
|
||||
_WEBHOOK_PATH = "/api/messages"
|
||||
|
||||
|
||||
class _AiohttpBridgeAdapter:
|
||||
"""HttpServerAdapter that bridges the Teams SDK into an aiohttp server.
|
||||
|
||||
Without a custom adapter, ``App()`` unconditionally imports fastapi/uvicorn
|
||||
and allocates a ``FastAPI()`` instance. This bridge captures the SDK's
|
||||
route registrations and wires them into our own aiohttp ``Application``.
|
||||
"""
|
||||
|
||||
def __init__(self, aiohttp_app: "web.Application"):
|
||||
self._aiohttp_app = aiohttp_app
|
||||
|
||||
def register_route(self, method: "HttpMethod", path: str, handler: "HttpRouteHandler") -> None:
|
||||
"""Register an SDK route handler as an aiohttp route."""
|
||||
|
||||
async def _aiohttp_handler(request: "web.Request") -> "web.Response":
|
||||
body = await request.json()
|
||||
headers = dict(request.headers)
|
||||
result: "HttpResponse" = await handler(HttpRequest(body=body, headers=headers))
|
||||
status = result.get("status", 200)
|
||||
resp_body = result.get("body")
|
||||
if resp_body is not None:
|
||||
return web.Response(
|
||||
status=status,
|
||||
body=json.dumps(resp_body),
|
||||
content_type="application/json",
|
||||
)
|
||||
return web.Response(status=status)
|
||||
|
||||
self._aiohttp_app.router.add_route(method, path, _aiohttp_handler)
|
||||
|
||||
def serve_static(self, path: str, directory: str) -> None:
|
||||
pass
|
||||
|
||||
async def start(self, port: int) -> None:
|
||||
raise NotImplementedError("aiohttp server is managed by the adapter")
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def check_requirements() -> bool:
|
||||
"""Return True when all Teams dependencies and credentials are present."""
|
||||
return TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Return True when the config has the minimum required credentials."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "")
|
||||
client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
|
||||
tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "")
|
||||
return bool(client_id and client_secret and tenant_id)
|
||||
|
||||
|
||||
def is_connected(config) -> bool:
|
||||
"""Check whether Teams is configured (env or config.yaml)."""
|
||||
return validate_config(config)
|
||||
|
||||
|
||||
# Keep the old name as an alias so existing test imports don't break.
|
||||
check_teams_requirements = check_requirements
|
||||
|
||||
|
||||
class TeamsAdapter(BasePlatformAdapter):
|
||||
"""Microsoft Teams adapter using the microsoft-teams-apps SDK."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 28000 # Teams text message limit (~28 KB)
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform("teams"))
|
||||
extra = config.extra or {}
|
||||
self._client_id = extra.get("client_id") or os.getenv("TEAMS_CLIENT_ID", "")
|
||||
self._client_secret = extra.get("client_secret") or os.getenv("TEAMS_CLIENT_SECRET", "")
|
||||
self._tenant_id = extra.get("tenant_id") or os.getenv("TEAMS_TENANT_ID", "")
|
||||
self._port = int(extra.get("port") or os.getenv("TEAMS_PORT", str(_DEFAULT_PORT)))
|
||||
self._app: Optional["App"] = None
|
||||
self._runner: Optional["web.AppRunner"] = None
|
||||
self._dedup = MessageDeduplicator(max_size=1000)
|
||||
# Maps chat_id → ConversationReference captured from incoming messages.
|
||||
# Used to send cards with the correct conversation type (personal/group/channel).
|
||||
self._conv_refs: Dict[str, Any] = {}
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if not TEAMS_SDK_AVAILABLE:
|
||||
self._set_fatal_error(
|
||||
"MISSING_SDK",
|
||||
"microsoft-teams-apps not installed. Run: pip install microsoft-teams-apps",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
self._set_fatal_error(
|
||||
"MISSING_SDK",
|
||||
"aiohttp not installed. Run: pip install aiohttp",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
|
||||
if not self._client_id or not self._client_secret or not self._tenant_id:
|
||||
self._set_fatal_error(
|
||||
"MISSING_CREDENTIALS",
|
||||
"TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Set up aiohttp app first — the bridge adapter wires SDK routes into it
|
||||
aiohttp_app = web.Application()
|
||||
aiohttp_app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
||||
|
||||
self._app = App(
|
||||
client_id=self._client_id,
|
||||
client_secret=self._client_secret,
|
||||
tenant_id=self._tenant_id,
|
||||
http_server_adapter=_AiohttpBridgeAdapter(aiohttp_app),
|
||||
)
|
||||
|
||||
# Register message handler before initialize()
|
||||
@self._app.on_message
|
||||
async def _handle_message(ctx: ActivityContext[MessageActivity]):
|
||||
await self._on_message(ctx)
|
||||
|
||||
@self._app.on_card_action
|
||||
async def _handle_card_action(
|
||||
ctx: ActivityContext[AdaptiveCardInvokeActivity],
|
||||
) -> InvokeResponse[AdaptiveCardActionMessageResponse]:
|
||||
return await self._on_card_action(ctx)
|
||||
|
||||
# initialize() calls register_route() on the bridge, which adds
|
||||
# POST /api/messages to aiohttp_app automatically
|
||||
await self._app.initialize()
|
||||
|
||||
self._runner = web.AppRunner(aiohttp_app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, "0.0.0.0", self._port)
|
||||
await site.start()
|
||||
|
||||
self._running = True
|
||||
self._mark_connected()
|
||||
logger.info(
|
||||
"[teams] Webhook server listening on 0.0.0.0:%d%s",
|
||||
self._port,
|
||||
_WEBHOOK_PATH,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._set_fatal_error(
|
||||
"CONNECT_FAILED",
|
||||
f"Teams connection failed: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
logger.error("[teams] Failed to connect: %s", e)
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._running = False
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
self._app = None
|
||||
self._mark_disconnected()
|
||||
logger.info("[teams] Disconnected")
|
||||
|
||||
async def _on_message(self, ctx: ActivityContext[MessageActivity]) -> None:
|
||||
"""Process an incoming Teams message and dispatch to the gateway."""
|
||||
activity = ctx.activity
|
||||
|
||||
# Self-message filter
|
||||
bot_id = self._app.id if self._app else None
|
||||
if bot_id and getattr(activity.from_, "id", None) == bot_id:
|
||||
return
|
||||
|
||||
# Deduplication
|
||||
msg_id = getattr(activity, "id", None)
|
||||
if msg_id and self._dedup.is_duplicate(msg_id):
|
||||
return
|
||||
|
||||
# Cache the conversation reference for proactive sends (approval cards, etc.)
|
||||
conv_id = getattr(activity.conversation, "id", None)
|
||||
if conv_id:
|
||||
self._conv_refs[conv_id] = ctx.conversation_ref
|
||||
|
||||
# Extract text — strip bot @mentions
|
||||
text = ""
|
||||
if hasattr(activity, "text") and activity.text:
|
||||
text = activity.text
|
||||
# Strip <at>BotName</at> HTML tags that Teams prepends for @mentions
|
||||
if "<at>" in text:
|
||||
import re
|
||||
text = re.sub(r"<at>[^<]*</at>\s*", "", text).strip()
|
||||
|
||||
# Determine chat type from conversation
|
||||
conv = activity.conversation
|
||||
conv_type = getattr(conv, "conversation_type", None) or ""
|
||||
if conv_type == "personal":
|
||||
chat_type = "dm"
|
||||
elif conv_type == "groupChat":
|
||||
chat_type = "group"
|
||||
elif conv_type == "channel":
|
||||
chat_type = "channel"
|
||||
else:
|
||||
chat_type = "dm"
|
||||
|
||||
# Build source
|
||||
from_account = activity.from_
|
||||
user_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "")
|
||||
user_name = getattr(from_account, "name", None) or ""
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=conv.id,
|
||||
chat_name=getattr(conv, "name", None) or "",
|
||||
chat_type=chat_type,
|
||||
user_id=str(user_id),
|
||||
user_name=user_name,
|
||||
guild_id=getattr(conv, "tenant_id", None) or self._tenant_id,
|
||||
)
|
||||
|
||||
# Handle image attachments
|
||||
media_urls = []
|
||||
media_types = []
|
||||
for att in getattr(activity, "attachments", None) or []:
|
||||
content_url = getattr(att, "content_url", None)
|
||||
content_type = getattr(att, "content_type", None) or ""
|
||||
if content_url and content_type.startswith("image/"):
|
||||
try:
|
||||
cached = await cache_image_from_url(content_url)
|
||||
if cached:
|
||||
media_urls.append(cached)
|
||||
media_types.append(content_type)
|
||||
except Exception as e:
|
||||
logger.warning("[teams] Failed to cache image attachment: %s", e)
|
||||
|
||||
msg_type = MessageType.PHOTO if media_urls else MessageType.TEXT
|
||||
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
source=source,
|
||||
message_type=msg_type,
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
message_id=msg_id,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
async def _send_card(self, chat_id: str, card: "AdaptiveCard") -> "Any":
|
||||
"""Send an AdaptiveCard, using a stored ConversationReference when available."""
|
||||
from microsoft_teams.api import MessageActivityInput
|
||||
|
||||
conv_ref = self._conv_refs.get(chat_id)
|
||||
if conv_ref and self._app:
|
||||
activity = MessageActivityInput().add_card(card)
|
||||
return await self._app.activity_sender.send(activity, conv_ref)
|
||||
elif self._app:
|
||||
return await self._app.send(chat_id, card)
|
||||
return None
|
||||
|
||||
async def _on_card_action(
|
||||
self, ctx: "ActivityContext[AdaptiveCardInvokeActivity]"
|
||||
) -> "InvokeResponse[AdaptiveCardActionMessageResponse]":
|
||||
"""Handle an Adaptive Card Action.Execute button click."""
|
||||
from tools.approval import resolve_gateway_approval, has_blocking_approval
|
||||
|
||||
action = ctx.activity.value.action
|
||||
data = action.data or {}
|
||||
hermes_action = data.get("hermes_action", "")
|
||||
session_key = data.get("session_key", "")
|
||||
|
||||
if not hermes_action or not session_key:
|
||||
return InvokeResponse(
|
||||
status=200,
|
||||
body=AdaptiveCardActionMessageResponse(value="Unknown action."),
|
||||
)
|
||||
|
||||
# Only authorized users may click approval buttons.
|
||||
allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip()
|
||||
if allowed_csv:
|
||||
from_account = ctx.activity.from_
|
||||
clicker_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "")
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and clicker_id not in allowed_ids:
|
||||
logger.warning("[teams] Unauthorized card action by %s — ignoring", clicker_id)
|
||||
return InvokeResponse(
|
||||
status=200,
|
||||
body=AdaptiveCardActionMessageResponse(value="⛔ Not authorized."),
|
||||
)
|
||||
|
||||
choice_map = {
|
||||
"approve_once": "once",
|
||||
"approve_session": "session",
|
||||
"approve_always": "always",
|
||||
"deny": "deny",
|
||||
}
|
||||
choice = choice_map.get(hermes_action)
|
||||
if not choice:
|
||||
return InvokeResponse(
|
||||
status=200,
|
||||
body=AdaptiveCardActionMessageResponse(value="Unknown action."),
|
||||
)
|
||||
|
||||
if not has_blocking_approval(session_key):
|
||||
return InvokeResponse(
|
||||
status=200,
|
||||
body=AdaptiveCardActionCardResponse(
|
||||
value=AdaptiveCard()
|
||||
.with_version("1.4")
|
||||
.with_body([TextBlock(text="⚠️ Approval already resolved or expired.", wrap=True)])
|
||||
),
|
||||
)
|
||||
|
||||
resolve_gateway_approval(session_key, choice)
|
||||
|
||||
label_map = {
|
||||
"once": "✅ Allowed (once)",
|
||||
"session": "✅ Allowed (session)",
|
||||
"always": "✅ Always allowed",
|
||||
"deny": "❌ Denied",
|
||||
}
|
||||
cmd = data.get("cmd", "")
|
||||
desc = data.get("desc", "")
|
||||
body = []
|
||||
if cmd:
|
||||
body.append(TextBlock(text="⚠️ Command Approval Required", wrap=True, weight="Bolder"))
|
||||
body.append(TextBlock(text=f"```\n{cmd}\n```", wrap=True))
|
||||
if desc:
|
||||
body.append(TextBlock(text=f"Reason: {desc}", wrap=True, isSubtle=True))
|
||||
body.append(TextBlock(text=label_map[choice], wrap=True, weight="Bolder"))
|
||||
|
||||
return InvokeResponse(
|
||||
status=200,
|
||||
body=AdaptiveCardActionCardResponse(
|
||||
value=AdaptiveCard().with_version("1.4").with_body(body)
|
||||
),
|
||||
)
|
||||
|
||||
async def send_exec_approval(
|
||||
self,
|
||||
chat_id: str,
|
||||
command: str,
|
||||
session_key: str,
|
||||
description: str = "dangerous command",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an Adaptive Card approval prompt with Allow/Deny buttons."""
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Teams app not initialized")
|
||||
|
||||
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
|
||||
# Truncated for button data payload — just enough to reconstruct the card body.
|
||||
btn_data_base = {
|
||||
"session_key": session_key,
|
||||
"cmd": command[:200] + "..." if len(command) > 200 else command,
|
||||
"desc": description,
|
||||
}
|
||||
|
||||
card = (
|
||||
AdaptiveCard()
|
||||
.with_version("1.4")
|
||||
.with_body([
|
||||
TextBlock(text="⚠️ Command Approval Required", wrap=True, weight="Bolder"),
|
||||
TextBlock(text=f"```\n{cmd_preview}\n```", wrap=True),
|
||||
TextBlock(text=f"Reason: {description}", wrap=True, isSubtle=True),
|
||||
])
|
||||
.with_actions([
|
||||
ExecuteAction(
|
||||
title="Allow Once",
|
||||
verb="hermes_approve",
|
||||
data={**btn_data_base, "hermes_action": "approve_once"},
|
||||
style="positive",
|
||||
),
|
||||
ExecuteAction(
|
||||
title="Allow Session",
|
||||
verb="hermes_approve",
|
||||
data={**btn_data_base, "hermes_action": "approve_session"},
|
||||
),
|
||||
ExecuteAction(
|
||||
title="Always Allow",
|
||||
verb="hermes_approve",
|
||||
data={**btn_data_base, "hermes_action": "approve_always"},
|
||||
),
|
||||
ExecuteAction(
|
||||
title="Deny",
|
||||
verb="hermes_approve",
|
||||
data={**btn_data_base, "hermes_action": "deny"},
|
||||
style="destructive",
|
||||
),
|
||||
])
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._send_card(chat_id, card)
|
||||
message_id = getattr(result, "id", None) if result else None
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as e:
|
||||
logger.error("[teams] send_exec_approval failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e), retryable=True)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Teams app not initialized")
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted)
|
||||
last_message_id = None
|
||||
|
||||
for chunk in chunks:
|
||||
try:
|
||||
result = await self._app.send(chat_id, chunk)
|
||||
last_message_id = getattr(result, "id", None)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e), retryable=True)
|
||||
|
||||
return SendResult(success=True, message_id=last_message_id)
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
if not self._app:
|
||||
return
|
||||
try:
|
||||
await self._app.send(chat_id, TypingActivityInput())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Teams app not initialized")
|
||||
|
||||
try:
|
||||
import base64
|
||||
import mimetypes
|
||||
from microsoft_teams.api import Attachment, MessageActivityInput
|
||||
|
||||
if image_url.startswith("http://") or image_url.startswith("https://"):
|
||||
content_url = image_url
|
||||
mime_type = "image/png"
|
||||
else:
|
||||
# Local path — encode as base64 data URI
|
||||
path = image_url.removeprefix("file://")
|
||||
mime_type = mimetypes.guess_type(path)[0] or "image/png"
|
||||
with open(path, "rb") as f:
|
||||
content_url = f"data:{mime_type};base64,{base64.b64encode(f.read()).decode()}"
|
||||
|
||||
attachment = Attachment(content_type=mime_type, content_url=content_url)
|
||||
activity = MessageActivityInput().add_attachments(attachment)
|
||||
if caption:
|
||||
activity = activity.add_text(caption)
|
||||
|
||||
conv_ref = self._conv_refs.get(chat_id)
|
||||
if conv_ref:
|
||||
result = await self._app.activity_sender.send(activity, conv_ref)
|
||||
else:
|
||||
result = await self._app.send(chat_id, activity)
|
||||
|
||||
return SendResult(success=True, message_id=getattr(result, "id", None))
|
||||
except Exception as e:
|
||||
logger.error("[teams] send_image failed: %s", e, exc_info=True)
|
||||
return SendResult(success=False, error=str(e), retryable=True)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
return await self.send_image(
|
||||
chat_id=chat_id,
|
||||
image_url=image_path,
|
||||
caption=caption,
|
||||
reply_to=reply_to,
|
||||
)
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> dict:
|
||||
return {"name": chat_id, "type": "unknown", "chat_id": chat_id}
|
||||
|
||||
|
||||
# ── Interactive setup ─────────────────────────────────────────────────────────
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Guide the user through Teams setup using the Teams CLI."""
|
||||
from hermes_cli.config import (
|
||||
get_env_value,
|
||||
save_env_value,
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
)
|
||||
|
||||
existing_id = get_env_value("TEAMS_CLIENT_ID")
|
||||
if existing_id:
|
||||
print_info(f"Teams: already configured (app ID: {existing_id})")
|
||||
if not prompt_yes_no("Reconfigure Teams?", False):
|
||||
return
|
||||
|
||||
print_info("You'll need the Teams CLI. If you haven't already:")
|
||||
print_info(" npm install -g @microsoft/teams.cli@preview")
|
||||
print_info(" teams login")
|
||||
print()
|
||||
print_info("Then expose port 3978 publicly (devtunnel / ngrok / cloudflared),")
|
||||
print_info("and create your bot:")
|
||||
print_info(" teams app create --name \"Hermes\" --endpoint \"https://<tunnel>/api/messages\"")
|
||||
print()
|
||||
print_info("The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.")
|
||||
print()
|
||||
|
||||
client_id = prompt("Client ID", default=existing_id or "")
|
||||
if not client_id:
|
||||
print_warning("Client ID is required — skipping Teams setup")
|
||||
return
|
||||
save_env_value("TEAMS_CLIENT_ID", client_id.strip())
|
||||
|
||||
client_secret = prompt("Client secret", default=get_env_value("TEAMS_CLIENT_SECRET") or "", password=True)
|
||||
if not client_secret:
|
||||
print_warning("Client secret is required — skipping Teams setup")
|
||||
return
|
||||
save_env_value("TEAMS_CLIENT_SECRET", client_secret.strip())
|
||||
|
||||
tenant_id = prompt("Tenant ID", default=get_env_value("TEAMS_TENANT_ID") or "")
|
||||
if not tenant_id:
|
||||
print_warning("Tenant ID is required — skipping Teams setup")
|
||||
return
|
||||
save_env_value("TEAMS_TENANT_ID", tenant_id.strip())
|
||||
|
||||
print()
|
||||
print_info("To find your AAD object ID for the allowlist: teams status --verbose")
|
||||
if prompt_yes_no("Restrict access to specific users? (recommended)", True):
|
||||
allowed = prompt(
|
||||
"Allowed AAD object IDs (comma-separated)",
|
||||
default=get_env_value("TEAMS_ALLOWED_USERS") or "",
|
||||
)
|
||||
if allowed:
|
||||
save_env_value("TEAMS_ALLOWED_USERS", allowed.replace(" ", ""))
|
||||
print_success("Allowlist configured")
|
||||
else:
|
||||
save_env_value("TEAMS_ALLOWED_USERS", "")
|
||||
else:
|
||||
save_env_value("TEAMS_ALLOW_ALL_USERS", "true")
|
||||
print_warning("⚠️ Open access — anyone who can message the bot can command it.")
|
||||
|
||||
print()
|
||||
print_success("Teams configuration saved to ~/.hermes/.env")
|
||||
print_info("Install the app in Teams: teams app install --id <teamsAppId>")
|
||||
print_info("Restart the gateway: hermes gateway restart")
|
||||
|
||||
|
||||
# ── Plugin entry point ────────────────────────────────────────────────────────
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="teams",
|
||||
label="Microsoft Teams",
|
||||
adapter_factory=lambda cfg: TeamsAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"],
|
||||
install_hint="pip install microsoft-teams-apps aiohttp",
|
||||
setup_fn=interactive_setup,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="TEAMS_ALLOWED_USERS",
|
||||
allow_all_env="TEAMS_ALLOW_ALL_USERS",
|
||||
# Teams supports up to ~28 KB per message
|
||||
max_message_length=28000,
|
||||
# Display
|
||||
emoji="💼",
|
||||
allow_update_command=True,
|
||||
# LLM guidance
|
||||
platform_hint=(
|
||||
"You are chatting via Microsoft Teams. Teams renders a subset of "
|
||||
"markdown — bold (**text**), italic (*text*), and inline code "
|
||||
"(`code`) work, but complex tables or raw HTML do not. Keep "
|
||||
"responses clear and professional."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
name: teams-platform
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Microsoft Teams gateway adapter for Hermes Agent.
|
||||
Connects to Microsoft Teams via the Bot Framework and relays messages
|
||||
between Teams chats (personal DMs, group chats, channel posts) and
|
||||
the Hermes agent. Supports Adaptive Card approval prompts.
|
||||
author: Aamir Jawaid
|
||||
requires_env:
|
||||
- TEAMS_CLIENT_ID
|
||||
- TEAMS_CLIENT_SECRET
|
||||
- TEAMS_TENANT_ID
|
||||
+125
-17
@@ -47,7 +47,12 @@ from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
# (a) the single in-module `OpenAI(**client_kwargs)` call site at
|
||||
# _create_openai_client, and
|
||||
# (b) `patch("run_agent.OpenAI", ...)` test patterns used by ~28 test files.
|
||||
import fire
|
||||
#
|
||||
# NOTE: `fire` is ONLY used in the `__main__` block below (for running
|
||||
# run_agent.py directly as a CLI) — it is NOT needed for library usage.
|
||||
# It is imported there, not here, so that importing run_agent from a
|
||||
# daemon thread (e.g. curator's forked review agent) never fails with
|
||||
# ModuleNotFoundError on broken/partial installs where `fire` isn't present.
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -323,6 +328,12 @@ _PATH_SCOPED_TOOLS = frozenset({"read_file", "write_file", "patch"})
|
||||
# Maximum number of concurrent worker threads for parallel tool execution.
|
||||
_MAX_TOOL_WORKERS = 8
|
||||
|
||||
# Guard so the OpenRouter metadata pre-warm thread is only spawned once per
|
||||
# process, not once per AIAgent instantiation. Without this, long-running
|
||||
# gateway processes leak one OS thread per incoming message and eventually
|
||||
# exhaust the system thread limit (RuntimeError: can't start new thread).
|
||||
_openrouter_prewarm_done = threading.Event()
|
||||
|
||||
# Patterns that indicate a terminal command may modify/delete files.
|
||||
_DESTRUCTIVE_PATTERNS = re.compile(
|
||||
r"""(?:^|\s|&&|\|\||;|`)(?:
|
||||
@@ -1107,10 +1118,17 @@ class AIAgent:
|
||||
# Pre-warm OpenRouter model metadata cache in a background thread.
|
||||
# fetch_model_metadata() is cached for 1 hour; this avoids a blocking
|
||||
# HTTP request on the first API response when pricing is estimated.
|
||||
if self.provider == "openrouter" or self._is_openrouter_url():
|
||||
# Use a process-level Event so this thread is only spawned once — a new
|
||||
# AIAgent is created for every gateway request, so without the guard
|
||||
# each message leaks one OS thread and the process eventually exhausts
|
||||
# the system thread limit (RuntimeError: can't start new thread).
|
||||
if (self.provider == "openrouter" or self._is_openrouter_url()) and \
|
||||
not _openrouter_prewarm_done.is_set():
|
||||
_openrouter_prewarm_done.set()
|
||||
threading.Thread(
|
||||
target=lambda: fetch_model_metadata(),
|
||||
target=fetch_model_metadata,
|
||||
daemon=True,
|
||||
name="openrouter-prewarm",
|
||||
).start()
|
||||
|
||||
self.tool_progress_callback = tool_progress_callback
|
||||
@@ -1898,6 +1916,7 @@ class AIAgent:
|
||||
self._ensure_lmstudio_runtime_loaded(_config_context_length)
|
||||
|
||||
|
||||
|
||||
# Select context engine: config-driven (like memory providers).
|
||||
# 1. Check config.yaml context.engine setting
|
||||
# 2. Check plugins/context_engine/<name>/ directory (repo-shipped)
|
||||
@@ -1987,16 +2006,31 @@ class AIAgent:
|
||||
f"model.context_length in config.yaml to override."
|
||||
)
|
||||
|
||||
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand)
|
||||
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).
|
||||
# Skip names that are already present — the get_tool_definitions()
|
||||
# quiet_mode cache returned a shared list pre-#17335, so a stray
|
||||
# mutation here would poison subsequent agent inits in the same
|
||||
# Gateway process and trip provider-side 'duplicate tool name'
|
||||
# errors. Even with the cache fix, dedup is the right defense
|
||||
# against plugin paths that may register the same schemas via
|
||||
# ctx.register_tool(). Mirrors the memory tools dedup above.
|
||||
self._context_engine_tool_names: set = set()
|
||||
if hasattr(self, "context_compressor") and self.context_compressor and self.tools is not None:
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in self.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in self.context_compressor.get_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin/cache path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
self.tools.append(_wrapped)
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname:
|
||||
self.valid_tool_names.add(_tname)
|
||||
self._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Notify context engine of session start
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
@@ -2035,6 +2069,8 @@ class AIAgent:
|
||||
# When running against an Ollama server, detect the model's max context
|
||||
# and pass num_ctx on every chat request so the full window is used.
|
||||
# User override: set model.ollama_num_ctx in config.yaml to cap VRAM use.
|
||||
# If model.context_length is set, it caps num_ctx so the user's VRAM
|
||||
# budget is respected even when GGUF metadata advertises a larger window.
|
||||
self._ollama_num_ctx: int | None = None
|
||||
_ollama_num_ctx_override = None
|
||||
if isinstance(_model_cfg, dict):
|
||||
@@ -2051,6 +2087,21 @@ class AIAgent:
|
||||
self._ollama_num_ctx = _detected
|
||||
except Exception as exc:
|
||||
logger.debug("Ollama num_ctx detection failed: %s", exc)
|
||||
# Cap auto-detected ollama_num_ctx to the user's explicit context_length.
|
||||
# Without this, GGUF metadata can advertise 256K+ which Ollama honours
|
||||
# by allocating that much VRAM — blowing up small GPUs even though the
|
||||
# user explicitly set a smaller context_length in config.yaml.
|
||||
if (
|
||||
self._ollama_num_ctx
|
||||
and _config_context_length
|
||||
and _ollama_num_ctx_override is None # don't override explicit ollama_num_ctx
|
||||
and self._ollama_num_ctx > _config_context_length
|
||||
):
|
||||
logger.info(
|
||||
"Ollama num_ctx capped: %d -> %d (model.context_length override)",
|
||||
self._ollama_num_ctx, _config_context_length,
|
||||
)
|
||||
self._ollama_num_ctx = _config_context_length
|
||||
if self._ollama_num_ctx and not self.quiet_mode:
|
||||
logger.info(
|
||||
"Ollama num_ctx: will request %d tokens (model max from /api/show)",
|
||||
@@ -2138,7 +2189,7 @@ class AIAgent:
|
||||
# Context engine reset (works for both built-in compressor and plugins)
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
self.context_compressor.on_session_reset()
|
||||
|
||||
|
||||
def _ensure_lmstudio_runtime_loaded(self, config_context_length: Optional[int] = None) -> None:
|
||||
"""
|
||||
Preload the LM Studio model with at least Hermes' minimum context.
|
||||
@@ -2922,7 +2973,7 @@ class AIAgent:
|
||||
|
||||
# Check if there's any non-whitespace content remaining
|
||||
return bool(cleaned.strip())
|
||||
|
||||
|
||||
def _strip_think_blocks(self, content: str) -> str:
|
||||
"""Remove reasoning/thinking blocks from content, returning only visible text.
|
||||
|
||||
@@ -3140,8 +3191,8 @@ class AIAgent:
|
||||
marker in assistant_text for marker in workspace_markers
|
||||
)
|
||||
return (user_targets_workspace or assistant_targets_workspace) and assistant_mentions_action
|
||||
|
||||
|
||||
|
||||
|
||||
def _extract_reasoning(self, assistant_message) -> Optional[str]:
|
||||
"""
|
||||
Extract reasoning/thinking content from an assistant message.
|
||||
@@ -3714,7 +3765,7 @@ class AIAgent:
|
||||
|
||||
# Return everything up to (not including) the last assistant message
|
||||
return messages[:last_assistant_idx]
|
||||
|
||||
|
||||
def _format_tools_for_system_message(self) -> str:
|
||||
"""
|
||||
Format tool definitions for the system message in the trajectory format.
|
||||
@@ -3738,7 +3789,7 @@ class AIAgent:
|
||||
formatted_tools.append(formatted_tool)
|
||||
|
||||
return json.dumps(formatted_tools, ensure_ascii=False)
|
||||
|
||||
|
||||
def _convert_to_trajectory_format(self, messages: List[Dict[str, Any]], user_query: str, completed: bool) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Convert internal message format to trajectory format for saving.
|
||||
@@ -3903,7 +3954,7 @@ class AIAgent:
|
||||
i += 1
|
||||
|
||||
return trajectory
|
||||
|
||||
|
||||
def _save_trajectory(self, messages: List[Dict[str, Any]], user_query: str, completed: bool):
|
||||
"""
|
||||
Save conversation trajectory to JSONL file.
|
||||
@@ -3918,7 +3969,7 @@ class AIAgent:
|
||||
|
||||
trajectory = self._convert_to_trajectory_format(messages, user_query, completed)
|
||||
_save_trajectory_to_file(trajectory, self.model, completed)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _summarize_api_error(error: Exception) -> str:
|
||||
"""Extract a human-readable one-liner from an API error.
|
||||
@@ -4230,7 +4281,7 @@ class AIAgent:
|
||||
except Exception as e:
|
||||
if self.verbose_logging:
|
||||
logging.warning(f"Failed to save session log: {e}")
|
||||
|
||||
|
||||
def interrupt(self, message: str = None) -> None:
|
||||
"""
|
||||
Request the agent to interrupt its current tool-calling loop.
|
||||
@@ -4298,7 +4349,7 @@ class AIAgent:
|
||||
logger.debug("Failed to propagate interrupt to child agent: %s", e)
|
||||
if not self.quiet_mode:
|
||||
print("\n⚡ Interrupt requested" + (f": '{message[:40]}...'" if message and len(message) > 40 else f": '{message}'" if message else ""))
|
||||
|
||||
|
||||
def clear_interrupt(self) -> None:
|
||||
"""Clear any pending interrupt request and the per-thread tool interrupt signal."""
|
||||
self._interrupt_requested = False
|
||||
@@ -4519,7 +4570,7 @@ class AIAgent:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def commit_memory_session(self, messages: list = None) -> None:
|
||||
"""Trigger end-of-session extraction without tearing providers down.
|
||||
Called when session_id rotates (e.g. /new, context compression);
|
||||
@@ -4715,7 +4766,7 @@ class AIAgent:
|
||||
if not self.quiet_mode:
|
||||
self._vprint(f"{self.log_prefix}📋 Restored {len(last_todo_response)} todo item(s) from history")
|
||||
_set_interrupt(False)
|
||||
|
||||
|
||||
@property
|
||||
def is_interrupted(self) -> bool:
|
||||
"""Check if an interrupt has been requested."""
|
||||
@@ -4899,6 +4950,15 @@ class AIAgent:
|
||||
platform_key = (self.platform or "").lower().strip()
|
||||
if platform_key in PLATFORM_HINTS:
|
||||
prompt_parts.append(PLATFORM_HINTS[platform_key])
|
||||
elif platform_key:
|
||||
# Check plugin registry for platform-specific LLM guidance
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
_entry = platform_registry.get(platform_key)
|
||||
if _entry and _entry.platform_hint:
|
||||
prompt_parts.append(_entry.platform_hint)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "\n\n".join(p.strip() for p in prompt_parts if p.strip())
|
||||
|
||||
@@ -6187,7 +6247,12 @@ class AIAgent:
|
||||
correctly — rebuilding with the Bedrock SDK when provider is bedrock,
|
||||
rather than always falling back to build_anthropic_client() which
|
||||
requires a direct Anthropic API key.
|
||||
|
||||
Honors ``self._oauth_1m_beta_disabled`` (set by the reactive recovery
|
||||
path when an OAuth subscription rejects the 1M-context beta) so the
|
||||
rebuilt client carries the reduced beta set.
|
||||
"""
|
||||
_drop_1m = bool(getattr(self, "_oauth_1m_beta_disabled", False))
|
||||
if getattr(self, "provider", None) == "bedrock":
|
||||
from agent.anthropic_adapter import build_anthropic_bedrock_client
|
||||
region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1"
|
||||
@@ -6198,6 +6263,7 @@ class AIAgent:
|
||||
self._anthropic_api_key,
|
||||
getattr(self, "_anthropic_base_url", None),
|
||||
timeout=get_provider_request_timeout(self.provider, self.model),
|
||||
drop_context_1m_beta=_drop_1m,
|
||||
)
|
||||
|
||||
def _interruptible_api_call(self, api_kwargs: dict):
|
||||
@@ -6502,6 +6568,9 @@ class AIAgent:
|
||||
Falls back to _interruptible_api_call on provider errors indicating
|
||||
streaming is not supported.
|
||||
"""
|
||||
if self._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before streaming API call")
|
||||
|
||||
if self.api_mode == "codex_responses":
|
||||
# Codex streams internally via _run_codex_stream. The main dispatch
|
||||
# in _interruptible_api_call already calls it; we just need to
|
||||
@@ -7160,6 +7229,12 @@ class AIAgent:
|
||||
# to non-streaming on the next attempt via _disable_streaming.
|
||||
result["error"] = e
|
||||
return
|
||||
except InterruptedError as e:
|
||||
# The interrupt may be noticed inside the worker thread before
|
||||
# the polling loop sees it. Surface it through the normal result
|
||||
# channel so callers never miss a fast pre-retry interrupt.
|
||||
result["error"] = e
|
||||
return
|
||||
finally:
|
||||
request_client = request_client_holder.get("client")
|
||||
if request_client is not None:
|
||||
@@ -8144,6 +8219,7 @@ class AIAgent:
|
||||
context_length=ctx_len,
|
||||
base_url=getattr(self, "_anthropic_base_url", None),
|
||||
fast_mode=(self.request_overrides or {}).get("speed") == "fast",
|
||||
drop_context_1m_beta=bool(getattr(self, "_oauth_1m_beta_disabled", False)),
|
||||
)
|
||||
|
||||
# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
|
||||
@@ -10729,6 +10805,7 @@ class AIAgent:
|
||||
copilot_auth_retry_attempted=False
|
||||
thinking_sig_retry_attempted = False
|
||||
image_shrink_retry_attempted = False
|
||||
oauth_1m_beta_retry_attempted = False
|
||||
has_retried_429 = False
|
||||
restart_with_compressed_messages = False
|
||||
restart_with_length_continuation = False
|
||||
@@ -11685,6 +11762,36 @@ class AIAgent:
|
||||
"or shrink didn't reduce size; surfacing original error."
|
||||
)
|
||||
|
||||
# Anthropic OAuth subscription rejected the 1M-context beta
|
||||
# header ("long context beta is not yet available for this
|
||||
# subscription"). Disable the beta for the rest of this
|
||||
# session, rebuild the client, and retry once. 1M-capable
|
||||
# subscriptions never hit this branch — they accept the
|
||||
# beta and keep full 1M context. See PR #17680 for the
|
||||
# original report (we chose reactive recovery over the
|
||||
# proposed unconditional omit so capable subscriptions
|
||||
# don't silently lose the capability).
|
||||
if (
|
||||
classified.reason == FailoverReason.oauth_long_context_beta_forbidden
|
||||
and self.api_mode == "anthropic_messages"
|
||||
and self._is_anthropic_oauth
|
||||
and not oauth_1m_beta_retry_attempted
|
||||
):
|
||||
oauth_1m_beta_retry_attempted = True
|
||||
if not getattr(self, "_oauth_1m_beta_disabled", False):
|
||||
self._oauth_1m_beta_disabled = True
|
||||
try:
|
||||
self._anthropic_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._rebuild_anthropic_client()
|
||||
self._vprint(
|
||||
f"{self.log_prefix}🔕 OAuth subscription doesn't support "
|
||||
f"the 1M-context beta — disabled for this session and retrying...",
|
||||
force=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
self.api_mode == "codex_responses"
|
||||
and self.provider == "openai-codex"
|
||||
@@ -13742,4 +13849,5 @@ def main(
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import fire
|
||||
fire.Fire(main)
|
||||
|
||||
@@ -42,6 +42,7 @@ AUTHOR_MAP = {
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"qiyin.zuo@pcitc.com": "qiyin-code",
|
||||
"leone.parise@gmail.com": "leoneparise",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||
"2093036+exiao@users.noreply.github.com": "exiao",
|
||||
@@ -53,13 +54,25 @@ AUTHOR_MAP = {
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
"charles@cryptoassetrecovery.com": "charles-brooks",
|
||||
"heathley@Heathley-MacBook-Air.local": "heathley",
|
||||
"vlad19@gmail.com": "dandaka",
|
||||
"adamrummer@gmail.com": "cyclingwithelephants",
|
||||
"nbot@liizfq.top": "liizfq",
|
||||
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
|
||||
"dejie.guo@gmail.com": "JayGwod",
|
||||
"maxence@groine.fr": "MaxyMoos",
|
||||
# OpenViking viking_read salvage (April 2026)
|
||||
"hitesh@gmail.com": "htsh",
|
||||
"pty819@outlook.com": "pty819",
|
||||
"pty819@users.noreply.github.com": "pty819",
|
||||
"517024110@qq.com": "chennest",
|
||||
# Curator fixes (Apr 30 2026)
|
||||
"yuxiangl490@gmail.com": "y0shua1ee",
|
||||
"manmit0x@gmail.com": "0xDevNinja",
|
||||
"aamirjawaid@microsoft.com": "heyitsaamir",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
"thomasjhon6666@gmail.com": "ThomassJonax",
|
||||
"focusflow.app.help@gmail.com": "yes999zc",
|
||||
"rob@atlas.lan": "rmoen",
|
||||
"162235745+0z1-ghb@users.noreply.github.com": "0z1-ghb",
|
||||
"yes999zc@163.com": "yes999zc",
|
||||
"343873859@qq.com": "DrStrangerUJN",
|
||||
@@ -78,6 +91,7 @@ AUTHOR_MAP = {
|
||||
"beesr@bee.localdomain": "beesrsj2500",
|
||||
"mtf201013@gmail.com": "ma-pony",
|
||||
"sonoyuncudmr@gmail.com": "Sonoyunchu",
|
||||
"43525405+yatesjalex@users.noreply.github.com": "yatesjalex",
|
||||
"maks.mir@yahoo.com": "say8hi",
|
||||
"27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky",
|
||||
"web3blind@users.noreply.github.com": "web3blind",
|
||||
@@ -105,9 +119,11 @@ AUTHOR_MAP = {
|
||||
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
||||
"keifergu@tencent.com": "keifergu",
|
||||
"kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
||||
"SHL0MS@users.noreply.github.com": "SHL0MS",
|
||||
"abner.the.foreman@agentmail.to": "Abnertheforeman",
|
||||
"adam.manning@pro-serveinc.com": "amanning3390",
|
||||
"thomasgeorgevii09@gmail.com": "tochukwuada",
|
||||
"sb@wmc.sh": "zicochaos",
|
||||
"harryykyle1@gmail.com": "hharry11",
|
||||
"kshitijk4poor@gmail.com": "kshitijk4poor",
|
||||
"1294707+Tosko4@users.noreply.github.com": "Tosko4",
|
||||
@@ -131,6 +147,7 @@ AUTHOR_MAP = {
|
||||
"126368201+vilkasdev@users.noreply.github.com": "vilkasdev",
|
||||
"137614867+cutepawss@users.noreply.github.com": "cutepawss",
|
||||
"96793918+memosr@users.noreply.github.com": "memosr",
|
||||
"mehmet.sr35@gmail.com": "memosr",
|
||||
"milkoor@users.noreply.github.com": "milkoor",
|
||||
"xuerui911@gmail.com": "Fatty911",
|
||||
"131039422+SHL0MS@users.noreply.github.com": "SHL0MS",
|
||||
@@ -339,6 +356,7 @@ AUTHOR_MAP = {
|
||||
"hgk324@gmail.com": "houziershi",
|
||||
"176644217+PStarH@users.noreply.github.com": "PStarH",
|
||||
"51058514+Sanjays2402@users.noreply.github.com": "Sanjays2402",
|
||||
"16577466+andy825@user.noreply.gitee.com": "Andy283",
|
||||
"906014227@qq.com": "bingo906",
|
||||
"aaronwong1999@icloud.com": "AaronWong1999",
|
||||
"agents@kylefrench.dev": "DeployFaith",
|
||||
@@ -607,6 +625,7 @@ AUTHOR_MAP = {
|
||||
"nfb0408@163.com": "ningfangbin",
|
||||
"164839249+Joseph19820124@users.noreply.github.com": "Joseph19820124",
|
||||
"rugved@lmstudio.ai": "rugvedS07",
|
||||
"44333070+Heltman@users.noreply.github.com": "Heltman",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+310
-349
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: comfyui
|
||||
description: "Generate images, video, and audio with ComfyUI — install, launch, manage nodes/models, run workflows with parameter injection. Uses the official comfy-cli for lifecycle and direct REST API for execution."
|
||||
version: 4.1.0
|
||||
requires: ComfyUI (local or Comfy Cloud); comfy-cli (pip install comfy-cli)
|
||||
description: "Generate images, video, and audio with ComfyUI — install, launch, manage nodes/models, run workflows with parameter injection. Uses the official comfy-cli for lifecycle and direct REST/WebSocket API for execution."
|
||||
version: 5.0.0
|
||||
author: [kshitijk4poor, alt-glitch]
|
||||
license: MIT
|
||||
platforms: [macos, linux, windows]
|
||||
compatibility: "Requires ComfyUI (local, Comfy Desktop, or Comfy Cloud) and comfy-cli (auto-installed via pipx/uvx by the setup script)."
|
||||
prerequisites:
|
||||
commands: ["python3"]
|
||||
setup:
|
||||
@@ -17,6 +17,9 @@ metadata:
|
||||
- image-generation
|
||||
- stable-diffusion
|
||||
- flux
|
||||
- sd3
|
||||
- wan-video
|
||||
- hunyuan-video
|
||||
- creative
|
||||
- generative-ai
|
||||
- video-generation
|
||||
@@ -26,325 +29,331 @@ metadata:
|
||||
|
||||
# ComfyUI
|
||||
|
||||
Generate images, video, and audio through ComfyUI using the official `comfy-cli` for
|
||||
setup/management and direct REST API calls for workflow execution.
|
||||
Generate images, video, audio, and 3D content through ComfyUI using the
|
||||
official `comfy-cli` for setup/lifecycle and direct REST/WebSocket API
|
||||
for workflow execution.
|
||||
|
||||
**Reference files in this skill:**
|
||||
## What's in this skill
|
||||
|
||||
- `references/official-cli.md` — comfy-cli command reference (install, launch, nodes, models)
|
||||
- `references/rest-api.md` — ComfyUI REST API endpoints (local + cloud)
|
||||
- `references/workflow-format.md` — workflow JSON format, common node types, parameter mapping
|
||||
**Reference docs (`references/`):**
|
||||
|
||||
**Scripts in this skill:**
|
||||
- `official-cli.md` — every `comfy ...` command, with flags
|
||||
- `rest-api.md` — REST + WebSocket endpoints (local + cloud), payload schemas
|
||||
- `workflow-format.md` — API-format JSON, common node types, param mapping
|
||||
|
||||
- `scripts/hardware_check.py` — detect GPU/VRAM/Apple Silicon, decide local vs Comfy Cloud
|
||||
- `scripts/comfyui_setup.sh` — full setup automation (hardware check + install + launch + verify)
|
||||
- `scripts/extract_schema.py` — reads workflow JSON, outputs which parameters are controllable
|
||||
- `scripts/run_workflow.py` — injects user args, submits workflow, monitors progress, downloads outputs
|
||||
- `scripts/check_deps.py` — checks if required custom nodes and models are installed
|
||||
**Scripts (`scripts/`):**
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `_common.py` | Shared HTTP, cloud routing, node catalogs (don't run directly) |
|
||||
| `hardware_check.py` | Probe GPU/VRAM/disk → recommend local vs Comfy Cloud |
|
||||
| `comfyui_setup.sh` | Hardware check + comfy-cli + ComfyUI install + launch + verify |
|
||||
| `extract_schema.py` | Read a workflow → list controllable params + model deps |
|
||||
| `check_deps.py` | Check workflow against running server → list missing nodes/models |
|
||||
| `auto_fix_deps.py` | Run check_deps then `comfy node install` / `comfy model download` |
|
||||
| `run_workflow.py` | Inject params, submit, monitor, download outputs (HTTP or WS) |
|
||||
| `run_batch.py` | Submit a workflow N times with sweeps, parallel up to your tier |
|
||||
| `ws_monitor.py` | Real-time WebSocket viewer for executing jobs (live progress) |
|
||||
| `health_check.py` | Verification checklist runner — comfy-cli + server + models + smoke test |
|
||||
| `fetch_logs.py` | Pull traceback / status messages for a given prompt_id |
|
||||
|
||||
**Example workflows (`workflows/`):** SD 1.5, SDXL, Flux Dev, SDXL img2img,
|
||||
SDXL inpaint, ESRGAN upscale, AnimateDiff video, Wan T2V. See
|
||||
`workflows/README.md`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks to generate images with Stable Diffusion, SDXL, Flux, or other diffusion models
|
||||
- User wants to run a specific ComfyUI workflow
|
||||
- User asks to generate images with Stable Diffusion, SDXL, Flux, SD3, etc.
|
||||
- User wants to run a specific ComfyUI workflow file
|
||||
- User wants to chain generative steps (txt2img → upscale → face restore)
|
||||
- User needs ControlNet, inpainting, img2img, or other advanced pipelines
|
||||
- User asks to manage ComfyUI queue, check models, or install custom nodes
|
||||
- User wants video/audio generation via AnimateDiff, Hunyuan, AudioCraft, etc.
|
||||
- User wants video/audio/3D generation via AnimateDiff, Hunyuan, Wan, AudioCraft, etc.
|
||||
|
||||
## Architecture: Two Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Layer 1: comfy-cli (official) │
|
||||
│ Setup, lifecycle, nodes, models │
|
||||
│ comfy install / launch / stop / node / model │
|
||||
│ Layer 1: comfy-cli (official lifecycle tool) │
|
||||
│ Setup, server lifecycle, custom nodes, models │
|
||||
│ → comfy install / launch / stop / node / model │
|
||||
└─────────────────────────┬───────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────▼───────────────────────────┐
|
||||
│ Layer 2: REST API + skill scripts │
|
||||
│ Layer 2: REST/WebSocket API + skill scripts │
|
||||
│ Workflow execution, param injection, monitoring │
|
||||
│ POST /api/prompt, GET /api/view, WebSocket │
|
||||
│ scripts/run_workflow.py, extract_schema.py │
|
||||
│ POST /api/prompt, GET /api/view, WS /ws │
|
||||
│ → run_workflow.py, run_batch.py, ws_monitor.py │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why two layers?** The official CLI handles installation and server management excellently
|
||||
but has minimal workflow execution support (just raw file submission, no param injection,
|
||||
no structured output). The REST API fills that gap — the scripts in this skill handle the
|
||||
param injection, execution monitoring, and output download that the CLI doesn't do.
|
||||
**Why two layers?** The official CLI is excellent for installation and server
|
||||
management but has minimal workflow execution support. The REST/WS API fills
|
||||
that gap — the scripts handle param injection, execution monitoring, and
|
||||
output download that the CLI doesn't do.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Detect Environment
|
||||
### Detect environment
|
||||
|
||||
```bash
|
||||
# What's available?
|
||||
command -v comfy >/dev/null 2>&1 && echo "comfy-cli: installed"
|
||||
curl -s http://127.0.0.1:8188/system_stats 2>/dev/null && echo "server: running"
|
||||
|
||||
# Can this machine actually run ComfyUI locally? (GPU/VRAM/Apple Silicon check)
|
||||
# Can this machine run ComfyUI locally? (GPU/VRAM/disk check)
|
||||
python3 scripts/hardware_check.py
|
||||
```
|
||||
|
||||
If nothing is installed, go to **Setup & Onboarding** below — but always run the
|
||||
hardware check first, before picking an install path.
|
||||
If the server is already running, skip to **Core Workflow**.
|
||||
If nothing is installed, see **Setup & Onboarding** below — but always run the
|
||||
hardware check first.
|
||||
|
||||
### One-line health check
|
||||
|
||||
```bash
|
||||
python3 scripts/health_check.py
|
||||
# → JSON: comfy_cli on PATH? server reachable? at least one checkpoint? smoke-test passes?
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### Step 1: Get a Workflow
|
||||
### Step 1: Get a workflow JSON in API format
|
||||
|
||||
Users provide workflow JSON files. These come from:
|
||||
- ComfyUI web editor → "Save (API Format)" button
|
||||
- Community downloads (civitai, Reddit, Discord)
|
||||
- The `scripts/` directory of this skill (example workflows)
|
||||
Workflows must be in API format (each node has `class_type`). They come from:
|
||||
|
||||
**The workflow must be in API format** (node IDs as keys with `class_type`).
|
||||
If user has editor format (has `nodes[]` and `links[]` at top level), they
|
||||
need to re-export using "Save (API Format)" in the ComfyUI web editor.
|
||||
- ComfyUI web UI → **Workflow → Export (API)** (newer UI) or
|
||||
the legacy "Save (API Format)" button (older UI)
|
||||
- This skill's `workflows/` directory (ready-to-run examples)
|
||||
- Community downloads (civitai, Reddit, Discord) — usually editor format,
|
||||
must be loaded into ComfyUI then re-exported
|
||||
|
||||
### Step 2: Understand What's Controllable
|
||||
Editor format (top-level `nodes` and `links` arrays) is **not directly
|
||||
executable**. The scripts detect this and tell you to re-export.
|
||||
|
||||
### Step 2: See what's controllable
|
||||
|
||||
```bash
|
||||
python3 scripts/extract_schema.py workflow_api.json --summary-only
|
||||
# → {"parameter_count": 12, "has_negative_prompt": true, "has_seed": true, ...}
|
||||
|
||||
python3 scripts/extract_schema.py workflow_api.json
|
||||
# → full schema with parameters, model deps, embedding refs
|
||||
```
|
||||
|
||||
Output (JSON):
|
||||
```json
|
||||
{
|
||||
"parameters": {
|
||||
"prompt": {"node_id": "6", "field": "text", "type": "string", "value": "a cat"},
|
||||
"negative_prompt": {"node_id": "7", "field": "text", "type": "string", "value": "bad quality"},
|
||||
"seed": {"node_id": "3", "field": "seed", "type": "int", "value": 42},
|
||||
"steps": {"node_id": "3", "field": "steps", "type": "int", "value": 20},
|
||||
"width": {"node_id": "5", "field": "width", "type": "int", "value": 512},
|
||||
"height": {"node_id": "5", "field": "height", "type": "int", "value": 512}
|
||||
}
|
||||
}
|
||||
```
|
||||
### Step 3: Run with parameters
|
||||
|
||||
### Step 3: Run with Parameters
|
||||
|
||||
**Local:**
|
||||
```bash
|
||||
# Local (defaults to http://127.0.0.1:8188)
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflow_api.json \
|
||||
--args '{"prompt": "a beautiful sunset over mountains", "seed": 123, "steps": 30}' \
|
||||
--args '{"prompt": "a beautiful sunset over mountains", "seed": -1, "steps": 30}' \
|
||||
--output-dir ./outputs
|
||||
```
|
||||
|
||||
**Cloud:**
|
||||
```bash
|
||||
# Cloud (export API key once; uses correct /api routing automatically)
|
||||
export COMFY_CLOUD_API_KEY="comfyui-..."
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflow_api.json \
|
||||
--args '{"prompt": "a beautiful sunset", "seed": 123}' \
|
||||
--args '{"prompt": "..."}' \
|
||||
--host https://cloud.comfy.org \
|
||||
--api-key "$COMFY_CLOUD_API_KEY" \
|
||||
--output-dir ./outputs
|
||||
|
||||
# Real-time progress via WebSocket (requires `pip install websocket-client`)
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow flux_dev.json \
|
||||
--args '{"prompt": "..."}' \
|
||||
--ws
|
||||
|
||||
# img2img / inpaint: pass --input-image to upload + reference automatically
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow sdxl_img2img.json \
|
||||
--input-image image=./photo.png \
|
||||
--args '{"prompt": "make it watercolor", "denoise": 0.6}'
|
||||
|
||||
# Batch / sweep: 8 random seeds, parallel up to cloud tier limit
|
||||
python3 scripts/run_batch.py \
|
||||
--workflow sdxl.json \
|
||||
--args '{"prompt": "abstract"}' \
|
||||
--count 8 --randomize-seed --parallel 3 \
|
||||
--output-dir ./outputs/batch
|
||||
```
|
||||
|
||||
### Step 4: Present Results
|
||||
`-1` for `seed` (or omitting it with `--randomize-seed`) generates a fresh
|
||||
random seed per run.
|
||||
|
||||
### Step 4: Present results
|
||||
|
||||
The scripts emit JSON to stdout describing every output file:
|
||||
|
||||
The script outputs JSON with file paths:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"prompt_id": "abc-123",
|
||||
"outputs": [
|
||||
{"file": "./outputs/ComfyUI_00001_.png", "node_id": "9", "type": "image"}
|
||||
{"file": "./outputs/sdxl_00001_.png", "node_id": "9",
|
||||
"type": "image", "filename": "sdxl_00001_.png"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Show images to the user via `vision_analyze` or return the file path directly.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
| User says | Tool | Command |
|
||||
|-----------|------|---------|
|
||||
| "install ComfyUI" | comfy-cli | `comfy install` |
|
||||
| **Lifecycle (use comfy-cli)** | | |
|
||||
| "install ComfyUI" | comfy-cli | `bash scripts/comfyui_setup.sh` |
|
||||
| "start ComfyUI" | comfy-cli | `comfy launch --background` |
|
||||
| "stop ComfyUI" | comfy-cli | `comfy stop` |
|
||||
| "install X node" | comfy-cli | `comfy node install <name>` |
|
||||
| "download X model" | comfy-cli | `comfy model download --url <url>` |
|
||||
| "download X model" | comfy-cli | `comfy model download --url <url> --relative-path models/checkpoints` |
|
||||
| "list installed models" | comfy-cli | `comfy model list` |
|
||||
| "list installed nodes" | comfy-cli | `comfy node show installed` |
|
||||
| "generate an image" | script | `run_workflow.py --args '{"prompt": "..."}'` |
|
||||
| "use this image" (img2img) | REST | upload image, then run_workflow.py |
|
||||
| "what can I change in this workflow?" | script | `extract_schema.py workflow.json` |
|
||||
| "check if workflow deps are met" | script | `check_deps.py workflow.json` |
|
||||
| "what's in the queue?" | REST | `curl http://HOST:8188/queue` |
|
||||
| **Execution (use scripts)** | | |
|
||||
| "is everything ready?" | script | `health_check.py` (optionally with `--workflow X --smoke-test`) |
|
||||
| "what can I change in this workflow?" | script | `extract_schema.py W.json` |
|
||||
| "check if W's deps are met" | script | `check_deps.py W.json` |
|
||||
| "fix missing deps" | script | `auto_fix_deps.py W.json` |
|
||||
| "generate an image" | script | `run_workflow.py --workflow W --args '{...}'` |
|
||||
| "use this image" (img2img) | script | `run_workflow.py --input-image image=./x.png ...` |
|
||||
| "8 variations with random seeds" | script | `run_batch.py --count 8 --randomize-seed ...` |
|
||||
| "show me live progress" | script | `ws_monitor.py --prompt-id <id>` |
|
||||
| "fetch the error from job X" | script | `fetch_logs.py <prompt_id>` |
|
||||
| **Direct REST** | | |
|
||||
| "what's in the queue?" | REST | `curl http://HOST:8188/queue` (local) or `--host https://cloud.comfy.org` |
|
||||
| "cancel that" | REST | `curl -X POST http://HOST:8188/interrupt` |
|
||||
| "free GPU memory" | REST | `curl -X POST http://HOST:8188/free` |
|
||||
|
||||
## Setup & Onboarding
|
||||
|
||||
When a user asks to set up ComfyUI, the FIRST thing to do is ask them whether
|
||||
they want **Comfy Cloud** (hosted, zero install, API key) or **Local** (install
|
||||
ComfyUI on their machine). Do NOT start running install commands or hardware
|
||||
When a user asks to set up ComfyUI, **the FIRST thing to do is ask whether
|
||||
they want Comfy Cloud (hosted, zero install, API key) or Local (install
|
||||
ComfyUI on their machine)**. Don't start running install commands or hardware
|
||||
checks until they've answered.
|
||||
|
||||
**Official docs:** https://docs.comfy.org/installation
|
||||
**CLI docs:** https://docs.comfy.org/comfy-cli/getting-started
|
||||
**Cloud docs:** https://docs.comfy.org/get_started/cloud
|
||||
**Cloud API:** https://docs.comfy.org/development/cloud/overview
|
||||
|
||||
### Step 0: Ask Local vs Cloud (ALWAYS FIRST)
|
||||
|
||||
Present the tradeoff clearly and wait for the user to choose. Suggested script:
|
||||
Suggested script:
|
||||
|
||||
> "Do you want to run ComfyUI locally on your machine, or use Comfy Cloud?
|
||||
>
|
||||
> - **Comfy Cloud** — hosted on RTX 6000 Pro GPUs, all models pre-installed, zero setup. Requires an API key (paid subscription). Best if you don't have a capable GPU or want to skip installation.
|
||||
> - **Comfy Cloud** — hosted on RTX 6000 Pro GPUs, all common models pre-installed,
|
||||
> zero setup. Requires an API key (paid subscription required to actually run
|
||||
> workflows; free tier is read-only). Best if you don't have a capable GPU.
|
||||
> - **Local** — free, but your machine MUST meet the hardware requirements:
|
||||
> - NVIDIA GPU with **≥6 GB VRAM** (≥8 GB recommended for SDXL, ≥12 GB for Flux/video), OR
|
||||
> - NVIDIA GPU with **≥6 GB VRAM** (≥8 GB for SDXL, ≥12 GB for Flux/video), OR
|
||||
> - AMD GPU with ROCm support (Linux), OR
|
||||
> - Apple Silicon Mac (M1 or newer) with **≥16 GB unified memory** (≥32 GB recommended).
|
||||
> - Apple Silicon Mac (M1+) with **≥16 GB unified memory** (≥32 GB recommended).
|
||||
> - Intel Macs and machines with no GPU will NOT work — use Cloud instead.
|
||||
>
|
||||
> Which would you like?"
|
||||
|
||||
Route based on their answer:
|
||||
Routing:
|
||||
|
||||
- **User picks Cloud** → skip to **Path A** (no hardware check needed).
|
||||
- **User picks Local** → go to **Step 1: Hardware Check** to verify their machine actually meets the requirements, then pick an install path from Paths B-E based on the verdict.
|
||||
- **User is unsure / asks for a recommendation** → run the hardware check anyway and let the verdict decide.
|
||||
- **Cloud** → skip to **Path A**.
|
||||
- **Local** → run hardware check first, then pick a path from Paths B–E based on the verdict.
|
||||
- **Unsure** → run the hardware check and let the verdict decide.
|
||||
|
||||
### Step 1: Verify Hardware (ONLY if user chose local)
|
||||
|
||||
```bash
|
||||
python3 scripts/hardware_check.py --json
|
||||
# Optional: also probe `torch` for actual CUDA/MPS:
|
||||
python3 scripts/hardware_check.py --json --check-pytorch
|
||||
```
|
||||
|
||||
It detects OS, GPU (NVIDIA CUDA / AMD ROCm / Apple Silicon / Intel Arc), VRAM,
|
||||
and unified/system RAM, then returns a verdict plus a suggested `comfy-cli` flag:
|
||||
| Verdict | Meaning | Action |
|
||||
|------------|---------------------------------------------------------------|--------|
|
||||
| `ok` | ≥8 GB VRAM (discrete) OR ≥32 GB unified (Apple Silicon) | Local install — use `comfy_cli_flag` from report |
|
||||
| `marginal` | SD1.5 works; SDXL tight; Flux/video unlikely | Local OK for light workflows, else **Path A (Cloud)** |
|
||||
| `cloud` | No usable GPU, <6 GB VRAM, <16 GB Apple unified, Intel Mac, Rosetta Python | **Switch to Cloud** unless user explicitly forces local |
|
||||
|
||||
| Verdict | Meaning | Action |
|
||||
|------------|-----------------------------------------------------------|-------------------------------------------------|
|
||||
| `ok` | ≥8 GB VRAM (discrete) OR ≥32 GB unified (Apple Silicon) | Local install — use `comfy_cli_flag` from report |
|
||||
| `marginal` | SD1.5 works; SDXL tight; Flux/video unlikely | Local OK for light workflows, else **Path A (Cloud)** |
|
||||
| `cloud` | No usable GPU, <6 GB VRAM, <16 GB Apple unified, Intel Mac | **User chose local but their machine doesn't meet requirements** — surface the `notes` and ask if they want to switch to Cloud |
|
||||
The script also surfaces `wsl: true` (WSL2 with NVIDIA passthrough) and
|
||||
`rosetta: true` (x86_64 Python on Apple Silicon — must reinstall as ARM64).
|
||||
|
||||
Hardware thresholds the skill enforces:
|
||||
|
||||
- **Discrete GPU minimum:** 6 GB VRAM. Below that, most modern models won't load.
|
||||
- **Apple Silicon:** M1 or newer (ARM64). Intel Macs have no MPS backend — Cloud only.
|
||||
- **Apple Silicon memory:** 16 GB unified minimum. 8 GB M1/M2 will swap/OOM on SDXL/Flux.
|
||||
- **No accelerator at all:** CPU-only is listed as a comfy-cli option but a single SDXL
|
||||
image takes 10+ minutes — treat it as unusable and route to Cloud.
|
||||
|
||||
If verdict is `cloud` but the user explicitly wanted local, DO NOT proceed
|
||||
silently. Show the `notes` array verbatim, explain which requirement they
|
||||
don't meet, and ask whether they want to (a) switch to Cloud or (b) force
|
||||
a local install anyway (marginal/cloud-verdict local installs will OOM or
|
||||
be unusably slow on modern models).
|
||||
|
||||
The report's `comfy_cli_flag` field gives you the exact flag for Step 2 below:
|
||||
`--nvidia`, `--amd`, or `--m-series`. For Intel Arc, use Path E (manual install).
|
||||
|
||||
Surface the `notes` array verbatim to the user so they understand why a
|
||||
particular path was recommended.
|
||||
If verdict is `cloud` but the user wants local, do not proceed silently.
|
||||
Show the `notes` array verbatim and ask whether they want to (a) switch to
|
||||
Cloud or (b) force a local install (will OOM or be unusably slow on modern models).
|
||||
|
||||
### Choosing an Installation Path
|
||||
|
||||
Use the hardware check result first. The table below is a fallback for when the user
|
||||
has already told you their hardware or you need to narrow down between multiple
|
||||
viable paths:
|
||||
Use the hardware check first. The table below is the fallback for when the
|
||||
user has already told you their hardware:
|
||||
|
||||
| Situation | Recommended Path |
|
||||
|-----------|-----------------|
|
||||
|-----------|------------------|
|
||||
| `verdict: cloud` from hardware check | **Path A: Comfy Cloud** |
|
||||
| No GPU / just want to try it | **Path A: Comfy Cloud** (zero setup) |
|
||||
| Windows + NVIDIA GPU + non-technical | **Path B: ComfyUI Desktop** (one-click installer) |
|
||||
| Windows + NVIDIA GPU + technical | **Path C: Portable** or **Path D: comfy-cli** |
|
||||
| Linux + any GPU | **Path D: comfy-cli** (easiest) or Path E manual |
|
||||
| macOS + Apple Silicon | **Path B: ComfyUI Desktop** or **Path D: comfy-cli** |
|
||||
| Headless / server / CI | **Path D: comfy-cli** |
|
||||
| No GPU / want to try without commitment | **Path A: Comfy Cloud** |
|
||||
| Windows + NVIDIA + non-technical | **Path B: ComfyUI Desktop** |
|
||||
| Windows + NVIDIA + technical | **Path C: Portable** or **Path D: comfy-cli** |
|
||||
| Linux + any GPU | **Path D: comfy-cli** (easiest) |
|
||||
| macOS + Apple Silicon | **Path B: Desktop** or **Path D: comfy-cli** |
|
||||
| Headless / server / CI / agents | **Path D: comfy-cli** |
|
||||
|
||||
For the fully automated path (hardware check → install → launch), just run:
|
||||
For the fully automated path (hardware check → install → launch → verify):
|
||||
|
||||
```bash
|
||||
bash scripts/comfyui_setup.sh
|
||||
# Or with overrides:
|
||||
bash scripts/comfyui_setup.sh --m-series --port=8190 --workspace=/data/comfy
|
||||
```
|
||||
|
||||
It runs `hardware_check.py` internally, refuses to install locally when the verdict
|
||||
is `cloud`, picks the right `comfy-cli` flag otherwise, then installs and launches.
|
||||
It runs `hardware_check.py` internally, refuses to install locally when the
|
||||
verdict is `cloud` (unless `--force-cloud-override`), picks the right
|
||||
`comfy-cli` flag, and prefers `pipx`/`uvx` over global `pip` to avoid polluting
|
||||
system Python.
|
||||
|
||||
---
|
||||
|
||||
### Path A: Comfy Cloud (No Local Install)
|
||||
|
||||
For users without a capable GPU or who want zero setup.
|
||||
Powered by RTX 6000 Pro GPUs, all models pre-installed.
|
||||
For users without a capable GPU or who want zero setup. Hosted on RTX 6000 Pro.
|
||||
|
||||
**Docs:** https://docs.comfy.org/get_started/cloud
|
||||
|
||||
1. Go to https://comfy.org/cloud and sign up
|
||||
2. Get an API key at https://platform.comfy.org/login
|
||||
- Click `+ New` in API Keys section → Generate
|
||||
- Save immediately (only visible once)
|
||||
1. Sign up at https://comfy.org/cloud
|
||||
2. Generate an API key at https://platform.comfy.org/login
|
||||
3. Set the key:
|
||||
```bash
|
||||
export COMFY_CLOUD_API_KEY="comfyui-xxxxxxxxxxxx"
|
||||
```
|
||||
4. Run workflows via the script or web UI:
|
||||
4. Run workflows:
|
||||
```bash
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflow_api.json \
|
||||
--args '{"prompt": "a cat"}' \
|
||||
--workflow workflows/flux_dev_txt2img.json \
|
||||
--args '{"prompt": "..."}' \
|
||||
--host https://cloud.comfy.org \
|
||||
--api-key "$COMFY_CLOUD_API_KEY" \
|
||||
--output-dir ./outputs
|
||||
```
|
||||
|
||||
**Pricing:** https://www.comfy.org/cloud/pricing
|
||||
Subscription required. Concurrent limits: Free/Standard: 1 job, Creator: 3, Pro: 5.
|
||||
**Concurrent jobs:** Free/Standard 1, Creator 3, Pro 5. Free tier
|
||||
**cannot run workflows via API** — only browse models. Paid subscription
|
||||
required for `/api/prompt`, `/api/upload/*`, `/api/view`, etc.
|
||||
|
||||
---
|
||||
|
||||
### Path B: ComfyUI Desktop (Windows/macOS)
|
||||
### Path B: ComfyUI Desktop (Windows / macOS)
|
||||
|
||||
One-click installer for non-technical users. Currently Beta.
|
||||
|
||||
**Docs:** https://docs.comfy.org/installation/desktop
|
||||
|
||||
- **Windows (NVIDIA):** https://download.comfy.org/windows/nsis/x64
|
||||
- **macOS (Apple Silicon):** Available from https://comfy.org (download page)
|
||||
- **macOS (Apple Silicon):** https://comfy.org
|
||||
|
||||
Steps:
|
||||
1. Download and run installer
|
||||
2. Select GPU type (NVIDIA recommended, or CPU mode)
|
||||
3. Choose install location (SSD recommended, ~15GB needed)
|
||||
4. Optionally migrate from existing ComfyUI Portable install
|
||||
5. Desktop launches automatically — web UI opens in browser
|
||||
|
||||
Desktop manages its own Python environment. For CLI access to the bundled env:
|
||||
```bash
|
||||
cd <install_dir>/ComfyUI
|
||||
.venv/Scripts/activate # Windows
|
||||
# or use the built-in terminal in the Desktop UI
|
||||
```
|
||||
|
||||
**Limitations:** Desktop uses stable releases (may lag behind latest).
|
||||
Linux not supported for Desktop — use comfy-cli or manual install.
|
||||
Linux is **not supported** for Desktop — use Path D.
|
||||
|
||||
---
|
||||
|
||||
### Path C: ComfyUI Portable (Windows Only)
|
||||
|
||||
Standalone package with embedded Python. Extract and run. No install.
|
||||
|
||||
**Docs:** https://docs.comfy.org/installation/comfyui_portable_windows
|
||||
|
||||
1. Download from https://github.com/comfyanonymous/ComfyUI/releases
|
||||
- Standard: Python 3.13 + CUDA 13.0 (modern NVIDIA GPUs)
|
||||
- Alt: PyTorch CUDA 12.6 + Python 3.12 (NVIDIA 10 series and older)
|
||||
- AMD (experimental)
|
||||
2. Extract with 7-Zip
|
||||
3. Run `run_nvidia_gpu.bat` (or `run_cpu.bat`)
|
||||
4. Wait for "To see the GUI go to: http://127.0.0.1:8188"
|
||||
|
||||
Update: run `update/update_comfyui.bat` (latest commit) or
|
||||
`update/update_comfyui_stable.bat` (latest stable release).
|
||||
Download from https://github.com/comfyanonymous/ComfyUI/releases, extract,
|
||||
run `run_nvidia_gpu.bat`. Update via `update/update_comfyui_stable.bat`.
|
||||
|
||||
---
|
||||
|
||||
@@ -353,22 +362,19 @@ Update: run `update/update_comfyui.bat` (latest commit) or
|
||||
The official CLI is the best path for headless/automated setups.
|
||||
|
||||
**Docs:** https://docs.comfy.org/comfy-cli/getting-started
|
||||
**Repo:** https://github.com/Comfy-Org/comfy-cli
|
||||
|
||||
#### Prerequisites
|
||||
- Python 3.10+ (3.13 recommended)
|
||||
- pip (or conda/uv)
|
||||
- GPU drivers installed (CUDA for NVIDIA, ROCm for AMD)
|
||||
|
||||
#### Install comfy-cli
|
||||
|
||||
```bash
|
||||
pip install comfy-cli
|
||||
# or
|
||||
# Recommended:
|
||||
pipx install comfy-cli
|
||||
# Or use uvx without installing:
|
||||
uvx --from comfy-cli comfy --help
|
||||
# Or (if pipx/uvx unavailable):
|
||||
pip install --user comfy-cli
|
||||
```
|
||||
|
||||
Disable analytics (avoids interactive prompt):
|
||||
Disable analytics non-interactively:
|
||||
```bash
|
||||
comfy --skip-prompt tracking disable
|
||||
```
|
||||
@@ -376,270 +382,225 @@ comfy --skip-prompt tracking disable
|
||||
#### Install ComfyUI
|
||||
|
||||
```bash
|
||||
# Interactive (prompts for GPU type)
|
||||
comfy install
|
||||
|
||||
# Non-interactive variants:
|
||||
comfy --skip-prompt install --nvidia # NVIDIA (CUDA)
|
||||
comfy --skip-prompt install --amd # AMD (ROCm, Linux)
|
||||
comfy --skip-prompt install --m-series # Apple Silicon (MPS)
|
||||
comfy --skip-prompt install --cpu # CPU only (slow)
|
||||
|
||||
# With faster dependency resolution:
|
||||
comfy --skip-prompt install --nvidia --fast-deps
|
||||
comfy --skip-prompt install --nvidia --fast-deps # uv-based dep resolution
|
||||
```
|
||||
|
||||
Default location: `~/comfy/ComfyUI` (Linux), `~/Documents/comfy/ComfyUI` (macOS/Win).
|
||||
Override with: `comfy --workspace /custom/path install`
|
||||
Default location: `~/comfy/ComfyUI` (Linux), `~/Documents/comfy/ComfyUI`
|
||||
(macOS/Win). Override with `comfy --workspace /custom/path install`.
|
||||
|
||||
#### Launch Server
|
||||
#### Launch / verify
|
||||
|
||||
```bash
|
||||
comfy launch --background # background daemon on :8188
|
||||
comfy launch # foreground (see logs)
|
||||
comfy launch -- --listen 0.0.0.0 # accessible on LAN
|
||||
comfy launch -- --port 8190 # custom port
|
||||
comfy launch -- --lowvram # low VRAM mode (6GB cards)
|
||||
```
|
||||
|
||||
Verify server is running:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8188/system_stats | python3 -m json.tool
|
||||
```
|
||||
|
||||
Stop background server:
|
||||
```bash
|
||||
comfy stop
|
||||
comfy launch --background # background daemon on :8188
|
||||
comfy launch -- --listen 0.0.0.0 --port 8190 # LAN-accessible custom port
|
||||
curl -s http://127.0.0.1:8188/system_stats # health check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Path E: Manual Install (Advanced / All Hardware)
|
||||
### Path E: Manual Install (Advanced / Unsupported Hardware)
|
||||
|
||||
For full control or unsupported hardware (Ascend NPU, Cambricon MLU, Intel Arc).
|
||||
For Ascend NPU, Cambricon MLU, Intel Arc, or other unsupported hardware.
|
||||
|
||||
**Docs:** https://docs.comfy.org/installation/manual_install
|
||||
**GitHub:** https://github.com/comfyanonymous/ComfyUI
|
||||
|
||||
```bash
|
||||
# 1. Create environment
|
||||
conda create -n comfyenv python=3.13
|
||||
conda activate comfyenv
|
||||
|
||||
# 2. Clone
|
||||
git clone https://github.com/comfyanonymous/ComfyUI.git
|
||||
cd ComfyUI
|
||||
|
||||
# 3. Install PyTorch (pick your hardware)
|
||||
# NVIDIA:
|
||||
pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130
|
||||
# AMD (ROCm 6.4):
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4
|
||||
# Apple Silicon:
|
||||
pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cpu
|
||||
# Intel Arc:
|
||||
pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/xpu
|
||||
# CPU only:
|
||||
pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# 4. Install ComfyUI deps
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 5. Run
|
||||
python main.py
|
||||
# With options: python main.py --listen 0.0.0.0 --port 8188
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Post-Install: Download Models
|
||||
|
||||
ComfyUI needs at least one checkpoint model to generate images.
|
||||
|
||||
**Using comfy-cli:**
|
||||
```bash
|
||||
# SDXL (general purpose, ~6.5GB)
|
||||
# SDXL (general purpose, ~6.5 GB)
|
||||
comfy model download \
|
||||
--url "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors" \
|
||||
--relative-path models/checkpoints
|
||||
|
||||
# SD 1.5 (lighter, ~4GB, good for low VRAM)
|
||||
# SD 1.5 (lighter, ~4 GB, good for 6 GB cards)
|
||||
comfy model download \
|
||||
--url "https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.safetensors" \
|
||||
--relative-path models/checkpoints
|
||||
|
||||
# From CivitAI (may need API token):
|
||||
# Flux Dev fp8 (smaller variant, ~12 GB)
|
||||
comfy model download \
|
||||
--url "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors" \
|
||||
--relative-path models/checkpoints
|
||||
|
||||
# CivitAI (set token first):
|
||||
comfy model download \
|
||||
--url "https://civitai.com/api/download/models/128713" \
|
||||
--relative-path models/checkpoints \
|
||||
--set-civitai-api-token "YOUR_TOKEN"
|
||||
|
||||
# LoRA adapters:
|
||||
comfy model download --url "<URL>" --relative-path models/loras
|
||||
```
|
||||
|
||||
**Manual download:** Place `.safetensors` / `.ckpt` files directly into the
|
||||
`ComfyUI/models/checkpoints/` directory (or `loras/`, `vae/`, etc.).
|
||||
|
||||
List installed models:
|
||||
```bash
|
||||
comfy model list
|
||||
```
|
||||
|
||||
---
|
||||
List installed: `comfy model list`.
|
||||
|
||||
### Post-Install: Install Custom Nodes
|
||||
|
||||
Custom nodes extend ComfyUI's capabilities (upscaling, video, ControlNet, etc.).
|
||||
|
||||
```bash
|
||||
comfy node install comfyui-impact-pack # popular utility pack
|
||||
comfy node install comfyui-animatediff-evolved # video generation
|
||||
comfy node install comfyui-controlnet-aux # ControlNet preprocessors
|
||||
comfy node install comfyui-essentials # common helpers
|
||||
comfy node update all # update all nodes
|
||||
comfy node install comfyui-impact-pack # popular utility pack
|
||||
comfy node install comfyui-animatediff-evolved # video generation
|
||||
comfy node install comfyui-controlnet-aux # ControlNet preprocessors
|
||||
comfy node install comfyui-essentials # common helpers
|
||||
comfy node update all
|
||||
comfy node install-deps --workflow=workflow.json # install everything a workflow needs
|
||||
```
|
||||
|
||||
Check what's installed:
|
||||
```bash
|
||||
comfy node show installed
|
||||
```
|
||||
|
||||
Install deps for a specific workflow:
|
||||
```bash
|
||||
comfy node install-deps --workflow=workflow_api.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Post-Install: Verify Setup
|
||||
### Post-Install: Verify
|
||||
|
||||
```bash
|
||||
# Check server is responsive
|
||||
curl -s http://127.0.0.1:8188/system_stats | python3 -m json.tool
|
||||
python3 scripts/health_check.py
|
||||
# → comfy_cli on PATH? server reachable? checkpoints? smoke test?
|
||||
|
||||
# Check a workflow's dependencies
|
||||
python3 scripts/check_deps.py workflow_api.json --host 127.0.0.1 --port 8188
|
||||
python3 scripts/check_deps.py my_workflow.json
|
||||
# → are this workflow's nodes/models/embeddings installed?
|
||||
|
||||
# Test a generation
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflow_api.json \
|
||||
--args '{"prompt": "test image, high quality"}' \
|
||||
--workflow workflows/sd15_txt2img.json \
|
||||
--args '{"prompt": "test", "steps": 4}' \
|
||||
--output-dir ./test-outputs
|
||||
```
|
||||
|
||||
## Image Upload (img2img / Inpainting)
|
||||
|
||||
Upload files directly via REST:
|
||||
The simplest way is to use `--input-image` with `run_workflow.py`:
|
||||
|
||||
```bash
|
||||
# Upload input image
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflows/sdxl_img2img.json \
|
||||
--input-image image=./photo.png \
|
||||
--args '{"prompt": "make it cyberpunk", "denoise": 0.6}'
|
||||
```
|
||||
|
||||
The flag uploads `photo.png`, then injects its server-side filename into
|
||||
whatever schema parameter is named `image`. For inpainting, pass both:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflows/sdxl_inpaint.json \
|
||||
--input-image image=./photo.png \
|
||||
--input-image mask_image=./mask.png \
|
||||
--args '{"prompt": "fill with flowers"}'
|
||||
```
|
||||
|
||||
Manual upload via REST:
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8188/upload/image" \
|
||||
-F "image=@photo.png" -F "type=input" -F "overwrite=true"
|
||||
# Returns: {"name": "photo.png", "subfolder": "", "type": "input"}
|
||||
|
||||
# Upload mask for inpainting
|
||||
curl -X POST "http://127.0.0.1:8188/upload/mask" \
|
||||
-F "image=@mask.png" -F "type=input" \
|
||||
-F 'original_ref={"filename":"photo.png","subfolder":"","type":"input"}'
|
||||
```
|
||||
|
||||
Then reference the uploaded filename in workflow args:
|
||||
```bash
|
||||
python3 scripts/run_workflow.py --workflow inpaint.json \
|
||||
--args '{"image": "photo.png", "mask": "mask.png", "prompt": "fill with flowers"}'
|
||||
```
|
||||
|
||||
## Cloud Execution
|
||||
|
||||
Base URL: `https://cloud.comfy.org`
|
||||
Auth: `X-API-Key` header
|
||||
|
||||
```bash
|
||||
# Submit workflow
|
||||
python3 scripts/run_workflow.py \
|
||||
--workflow workflow_api.json \
|
||||
--args '{"prompt": "cyberpunk city"}' \
|
||||
--host https://cloud.comfy.org \
|
||||
--api-key "$COMFY_CLOUD_API_KEY" \
|
||||
--output-dir ./outputs \
|
||||
--timeout 300
|
||||
|
||||
# Upload image for cloud workflows
|
||||
# Cloud equivalent:
|
||||
curl -X POST "https://cloud.comfy.org/api/upload/image" \
|
||||
-H "X-API-Key: $COMFY_CLOUD_API_KEY" \
|
||||
-F "image=@input.png" -F "type=input" -F "overwrite=true"
|
||||
-F "image=@photo.png" -F "type=input" -F "overwrite=true"
|
||||
```
|
||||
|
||||
Concurrent job limits:
|
||||
| Tier | Concurrent Jobs |
|
||||
|------|----------------|
|
||||
| Free/Standard | 1 |
|
||||
| Creator | 3 |
|
||||
| Pro | 5 |
|
||||
## Cloud Specifics
|
||||
|
||||
Extra submissions queue automatically.
|
||||
- **Base URL:** `https://cloud.comfy.org`
|
||||
- **Auth:** `X-API-Key` header (or `?token=KEY` for WebSocket)
|
||||
- **API key:** set `$COMFY_CLOUD_API_KEY` once and the scripts pick it up automatically
|
||||
- **Output download:** `/api/view` returns a 302 to a signed URL; the scripts
|
||||
follow it and strip `X-API-Key` before fetching from the storage backend
|
||||
(don't leak the API key to S3/CloudFront).
|
||||
- **Endpoint differences from local ComfyUI:**
|
||||
- `/api/object_info`, `/api/queue`, `/api/userdata` — **403 on free tier**;
|
||||
paid only.
|
||||
- `/history` is renamed to `/history_v2` on cloud (the scripts route
|
||||
automatically).
|
||||
- `/models/<folder>` is renamed to `/experiment/models/<folder>` on cloud
|
||||
(the scripts route automatically).
|
||||
- `clientId` in WebSocket is currently ignored — all connections for a
|
||||
user receive the same broadcast. Filter by `prompt_id` client-side.
|
||||
- `subfolder` is accepted on uploads but ignored — cloud has a flat namespace.
|
||||
- **Concurrent jobs:** Free/Standard: 1, Creator: 3, Pro: 5. Extras queue
|
||||
automatically. Use `run_batch.py --parallel N` to saturate your tier.
|
||||
|
||||
## Queue & System Management
|
||||
|
||||
```bash
|
||||
# Check queue
|
||||
# Local
|
||||
curl -s http://127.0.0.1:8188/queue | python3 -m json.tool
|
||||
|
||||
# Clear pending queue
|
||||
curl -X POST http://127.0.0.1:8188/queue -d '{"clear": true}'
|
||||
|
||||
# Cancel running job
|
||||
curl -X POST http://127.0.0.1:8188/interrupt
|
||||
|
||||
# Free GPU memory (unload all models)
|
||||
curl -X POST http://127.0.0.1:8188/free -H "Content-Type: application/json" \
|
||||
curl -X POST http://127.0.0.1:8188/queue -d '{"clear": true}' # cancel pending
|
||||
curl -X POST http://127.0.0.1:8188/interrupt # cancel running
|
||||
curl -X POST http://127.0.0.1:8188/free \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"unload_models": true, "free_memory": true}'
|
||||
|
||||
# System stats (VRAM, RAM, GPU info)
|
||||
curl -s http://127.0.0.1:8188/system_stats | python3 -m json.tool
|
||||
# Cloud — same paths under /api/, plus:
|
||||
python3 scripts/fetch_logs.py --tail-queue --host https://cloud.comfy.org
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **API format required** — `comfy run` and the scripts only accept API-format workflow JSON.
|
||||
If the user has editor format (from "Save" not "Save (API Format)"), they need to
|
||||
re-export. Check: API format has `class_type` in each node object, editor format has
|
||||
top-level `nodes` and `links` arrays.
|
||||
1. **API format required** — every script and the `/api/prompt` endpoint expect
|
||||
API-format workflow JSON. The scripts detect editor format (top-level
|
||||
`nodes` and `links` arrays) and tell you to re-export via
|
||||
"Workflow → Export (API)" (newer UI) or "Save (API Format)" (older UI).
|
||||
|
||||
2. **Server must be running** — All execution requires a live server. `comfy launch --background`
|
||||
starts one. Check with `curl http://127.0.0.1:8188/system_stats`.
|
||||
2. **Server must be running** — all execution requires a live server.
|
||||
`comfy launch --background` starts one. Verify with
|
||||
`curl http://127.0.0.1:8188/system_stats`.
|
||||
|
||||
3. **Model names are exact** — Case-sensitive, includes file extension. Use
|
||||
3. **Model names are exact** — case-sensitive, includes file extension.
|
||||
`check_deps.py` does fuzzy matching (with/without extension and folder
|
||||
prefix), but the workflow itself must use the canonical name. Use
|
||||
`comfy model list` to discover what's installed.
|
||||
|
||||
4. **Missing custom nodes** — "class_type not found" means a required node isn't installed.
|
||||
Run `check_deps.py` to find what's missing, then `comfy node install <name>`.
|
||||
4. **Missing custom nodes** — "class_type not found" means a required node
|
||||
isn't installed. `check_deps.py` reports which package to install;
|
||||
`auto_fix_deps.py` runs the install for you.
|
||||
|
||||
5. **Working directory** — `comfy-cli` auto-detects the ComfyUI workspace. If commands
|
||||
fail with "no workspace found", use `comfy --workspace /path/to/ComfyUI <command>`
|
||||
or `comfy set-default /path/to/ComfyUI`.
|
||||
5. **Working directory** — `comfy-cli` auto-detects the ComfyUI workspace.
|
||||
If commands fail with "no workspace found", use
|
||||
`comfy --workspace /path/to/ComfyUI <command>` or
|
||||
`comfy set-default /path/to/ComfyUI`.
|
||||
|
||||
6. **Cloud vs local output download** — Cloud `/api/view` returns a 302 redirect to a
|
||||
signed URL. Always follow redirects (`curl -L`). The `run_workflow.py` script handles
|
||||
this automatically.
|
||||
6. **Cloud free-tier API limits** — `/api/prompt`, `/api/view`, `/api/upload/*`,
|
||||
`/api/object_info` all return 403 on free accounts. `health_check.py` and
|
||||
`check_deps.py` handle this gracefully and surface a clear message.
|
||||
|
||||
7. **Timeout for video/audio** — Long generations (video, high step counts) can take
|
||||
minutes. Pass `--timeout 600` to `run_workflow.py`. Default is 120 seconds.
|
||||
7. **Timeout for video/audio workflows** — auto-detected when an output node
|
||||
is `VHS_VideoCombine`, `SaveVideo`, etc.; the default jumps from 300 s to
|
||||
900 s. Override explicitly with `--timeout 1800`.
|
||||
|
||||
8. **tracking prompt** — First run of `comfy` may prompt for analytics tracking consent.
|
||||
Use `comfy --skip-prompt tracking disable` to skip it non-interactively.
|
||||
8. **Path traversal in output filenames** — server-supplied filenames are
|
||||
passed through `safe_path_join` to refuse anything escaping `--output-dir`.
|
||||
Keep this protection on — workflows with custom save nodes can produce
|
||||
arbitrary paths.
|
||||
|
||||
9. **comfy-cli invocation via uvx** — If comfy-cli is not installed globally, invoke with
|
||||
`uvx --from comfy-cli comfy <command>`. All examples in this skill use bare `comfy`
|
||||
but prepend `uvx --from comfy-cli` if needed.
|
||||
9. **Workflow JSON is arbitrary code** — custom nodes run Python, so
|
||||
submitting an unknown workflow has the same trust profile as `eval`.
|
||||
Inspect workflows from untrusted sources before running.
|
||||
|
||||
10. **Auto-randomized seed** — pass `seed: -1` in `--args` (or use
|
||||
`--randomize-seed` and omit the seed) to get a fresh seed per run.
|
||||
The actual seed is logged to stderr.
|
||||
|
||||
11. **`tracking` prompt** — first run of `comfy` may prompt for analytics.
|
||||
Use `comfy --skip-prompt tracking disable` to skip non-interactively.
|
||||
`comfyui_setup.sh` does this for you.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Use `python3 scripts/health_check.py` to run the whole list at once. Manual:
|
||||
|
||||
- [ ] `hardware_check.py` verdict is `ok` OR the user explicitly chose Comfy Cloud
|
||||
- [ ] `comfy` available on PATH (or `uvx --from comfy-cli comfy --help` works)
|
||||
- [ ] `curl http://127.0.0.1:8188/system_stats` returns JSON
|
||||
- [ ] `comfy model list` shows at least one checkpoint
|
||||
- [ ] Workflow JSON is in API format (has `class_type` keys)
|
||||
- [ ] `check_deps.py` reports no missing nodes/models
|
||||
- [ ] Test run completes and outputs are saved
|
||||
- [ ] `comfy --version` works (or `uvx --from comfy-cli comfy --help`)
|
||||
- [ ] `curl http://HOST:PORT/system_stats` returns JSON
|
||||
- [ ] `comfy model list` shows at least one checkpoint (local) OR
|
||||
`/api/experiment/models/checkpoints` returns models (cloud)
|
||||
- [ ] Workflow JSON is in API format
|
||||
- [ ] `check_deps.py` reports `is_ready: true` (or only `node_check_skipped`
|
||||
on cloud free tier)
|
||||
- [ ] Test run with a small workflow completes; outputs land in `--output-dir`
|
||||
|
||||
@@ -5,12 +5,16 @@ Docs: https://docs.comfy.org/comfy-cli/getting-started
|
||||
|
||||
## Installation
|
||||
|
||||
Order of preference:
|
||||
|
||||
```bash
|
||||
pip install comfy-cli
|
||||
# or
|
||||
uvx --from comfy-cli comfy --help
|
||||
pipx install comfy-cli # recommended (isolated env)
|
||||
uvx --from comfy-cli comfy --help # zero-install via uv
|
||||
pip install --user comfy-cli # fallback
|
||||
```
|
||||
|
||||
The skill's `comfyui_setup.sh` picks the best available method.
|
||||
|
||||
First run may prompt for analytics. Disable non-interactively:
|
||||
```bash
|
||||
comfy --skip-prompt tracking disable
|
||||
@@ -32,9 +36,9 @@ Workspace resolution priority:
|
||||
3. `--here` (cwd)
|
||||
4. `comfy set-default` path
|
||||
5. Most recently used
|
||||
6. `~/comfy/ComfyUI` (Linux) or `~/Documents/comfy/ComfyUI` (macOS)
|
||||
6. `~/comfy/ComfyUI` (Linux) or `~/Documents/comfy/ComfyUI` (macOS/Win)
|
||||
|
||||
## Commands
|
||||
## Lifecycle Commands
|
||||
|
||||
### `comfy install`
|
||||
|
||||
@@ -42,65 +46,53 @@ Download and install ComfyUI + ComfyUI-Manager.
|
||||
|
||||
```bash
|
||||
comfy install # interactive GPU selection
|
||||
comfy install --nvidia # NVIDIA (CUDA)
|
||||
comfy install --amd # AMD (ROCm)
|
||||
comfy install --nvidia
|
||||
comfy install --amd # ROCm (Linux)
|
||||
comfy install --m-series # Apple Silicon (MPS)
|
||||
comfy install --cpu # CPU only
|
||||
comfy install --fast-deps # use uv for faster deps
|
||||
comfy install --cpu # CPU only (slow)
|
||||
comfy install --fast-deps # use uv for deps
|
||||
comfy install --skip-manager # skip ComfyUI-Manager
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--nvidia` | NVIDIA GPU |
|
||||
| `--amd` | AMD GPU (ROCm) |
|
||||
| `--m-series` | Apple Silicon |
|
||||
| `--cpu` | CPU only |
|
||||
| `--nvidia` / `--amd` / `--m-series` / `--cpu` | GPU type |
|
||||
| `--cuda-version` | 11.8, 12.1, 12.4, 12.6, 12.8, 12.9, 13.0 |
|
||||
| `--rocm-version` | 6.1, 6.2, 6.3, 7.0, 7.1 |
|
||||
| `--fast-deps` | Use uv for dependency resolution |
|
||||
| `--fast-deps` | uv-based dependency resolution |
|
||||
| `--skip-manager` | Don't install ComfyUI-Manager |
|
||||
| `--skip-torch-or-directml` | Skip PyTorch install |
|
||||
| `--version <ver>` | Specific ComfyUI version (e.g. `0.2.0`, `latest`, `nightly`) |
|
||||
| `--version <ver>` | `0.2.0`, `latest`, `nightly` |
|
||||
| `--commit <hash>` | Install specific commit |
|
||||
| `--pr "#1234"` | Install from a PR |
|
||||
| `--restore` | Restore deps for existing install |
|
||||
|
||||
Default location: `~/comfy/ComfyUI` (Linux), `~/Documents/comfy/ComfyUI` (macOS/Win).
|
||||
|
||||
### `comfy launch`
|
||||
|
||||
Start ComfyUI server.
|
||||
|
||||
```bash
|
||||
comfy launch # foreground on :8188
|
||||
comfy launch --background # background daemon
|
||||
comfy launch -- --listen 0.0.0.0 # listen on all interfaces
|
||||
comfy launch -- --port 8190 # custom port
|
||||
comfy launch -- --cpu # force CPU mode
|
||||
comfy launch # foreground :8188
|
||||
comfy launch --background # background daemon
|
||||
comfy launch -- --listen 0.0.0.0 # LAN-accessible
|
||||
comfy launch -- --port 8190 # custom port
|
||||
comfy launch -- --cpu # force CPU mode
|
||||
comfy launch -- --lowvram # 6 GB cards
|
||||
comfy launch --background -- --listen 0.0.0.0 --port 8190
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--background` | Run as background daemon |
|
||||
| `--frontend-pr "#456"` | Test a frontend PR |
|
||||
| Extra args after `--` | Passed directly to ComfyUI's `main.py` |
|
||||
|
||||
Common extra args: `--listen`, `--port`, `--cpu`, `--lowvram`, `--novram`,
|
||||
`--fp16-vae`, `--force-fp32`.
|
||||
Common extra args after `--`: `--listen`, `--port`, `--cpu`, `--lowvram`,
|
||||
`--novram`, `--fp16-vae`, `--force-fp32`, `--disable-cuda-malloc`.
|
||||
|
||||
### `comfy stop`
|
||||
|
||||
Stop background ComfyUI instance.
|
||||
|
||||
```bash
|
||||
comfy stop
|
||||
```
|
||||
|
||||
### `comfy run`
|
||||
|
||||
Execute a raw workflow JSON file against a running server.
|
||||
Submit a raw workflow JSON to a running server. **Limited** — no parameter
|
||||
injection, no structured output download. For agents, use
|
||||
`scripts/run_workflow.py` instead.
|
||||
|
||||
```bash
|
||||
comfy run --workflow workflow_api.json
|
||||
@@ -108,31 +100,15 @@ comfy run --workflow workflow_api.json --host 10.0.0.5 --port 8188
|
||||
comfy run --workflow workflow_api.json --timeout 300 --wait
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--workflow` | Path to API-format workflow JSON (required) |
|
||||
| `--host` | Server hostname (default: 127.0.0.1) |
|
||||
| `--port` | Server port (default: 8188) |
|
||||
| `--timeout` | Seconds to wait (default: 30) |
|
||||
| `--wait/--no-wait` | Wait for completion (default: wait) |
|
||||
| `--verbose` | Show per-node execution details |
|
||||
|
||||
**Limitations:** No parameter injection, no structured output, no image download.
|
||||
For agent use, prefer `scripts/run_workflow.py` which adds those capabilities.
|
||||
|
||||
### `comfy which`
|
||||
|
||||
Show which ComfyUI workspace is currently targeted.
|
||||
|
||||
```bash
|
||||
comfy which
|
||||
comfy which # show targeted workspace
|
||||
comfy --recent which
|
||||
```
|
||||
|
||||
### `comfy set-default`
|
||||
|
||||
Set the default workspace path.
|
||||
|
||||
```bash
|
||||
comfy set-default /path/to/ComfyUI
|
||||
comfy set-default /path/to/ComfyUI --launch-extras="--listen 0.0.0.0"
|
||||
@@ -140,8 +116,6 @@ comfy set-default /path/to/ComfyUI --launch-extras="--listen 0.0.0.0"
|
||||
|
||||
### `comfy update`
|
||||
|
||||
Update ComfyUI or custom nodes.
|
||||
|
||||
```bash
|
||||
comfy update # update ComfyUI core
|
||||
comfy node update all # update all custom nodes
|
||||
@@ -151,33 +125,32 @@ comfy node update all # update all custom nodes
|
||||
|
||||
## `comfy node` — Custom Node Management
|
||||
|
||||
All node operations use ComfyUI-Manager (cm-cli) under the hood.
|
||||
All node operations use ComfyUI-Manager (`cm-cli`) under the hood.
|
||||
|
||||
```bash
|
||||
comfy node show installed # list installed nodes
|
||||
comfy node show enabled # list enabled nodes
|
||||
comfy node show all # all available nodes
|
||||
comfy node show installed # list installed
|
||||
comfy node show enabled # list enabled
|
||||
comfy node show all # all available in registry
|
||||
comfy node simple-show installed # compact list
|
||||
|
||||
comfy node install comfyui-impact-pack # install by name
|
||||
comfy node install <name> --uv-compile # with unified dep resolution (Manager v4.1+)
|
||||
comfy node uninstall <name> # remove
|
||||
comfy node update <name> # update one
|
||||
comfy node update all # update all
|
||||
comfy node enable <name> # enable disabled node
|
||||
comfy node disable <name> # disable without uninstalling
|
||||
comfy node fix <name> # fix broken dependencies
|
||||
comfy node install comfyui-impact-pack
|
||||
comfy node install <name> --uv-compile # ComfyUI-Manager v4.1+ unified resolver
|
||||
comfy node uninstall <name>
|
||||
comfy node update <name> | all
|
||||
comfy node enable <name>
|
||||
comfy node disable <name>
|
||||
comfy node fix <name> # fix broken deps
|
||||
|
||||
comfy node install-deps --workflow=workflow.json # install all deps a workflow needs
|
||||
comfy node deps-in-workflow --workflow=w.json --output=deps.json # extract dep list
|
||||
comfy node install-deps --workflow=workflow.json
|
||||
comfy node deps-in-workflow --workflow=w.json --output=deps.json
|
||||
|
||||
comfy node save-snapshot # save current state
|
||||
comfy node restore-snapshot <file> # restore from snapshot
|
||||
comfy node save-snapshot
|
||||
comfy node restore-snapshot <file>
|
||||
|
||||
comfy node bisect start # find culprit node (binary search)
|
||||
comfy node bisect good # current set is fine
|
||||
comfy node bisect bad # problem is in current set
|
||||
comfy node bisect reset # abort bisect
|
||||
comfy node bisect start # binary-search a culprit node
|
||||
comfy node bisect good
|
||||
comfy node bisect bad
|
||||
comfy node bisect reset
|
||||
```
|
||||
|
||||
### Dependency Resolution Options
|
||||
@@ -188,21 +161,21 @@ comfy node bisect reset # abort bisect
|
||||
| `--uv-compile` | ComfyUI-Manager v4.1+ unified resolver (recommended) |
|
||||
| `--no-deps` | Skip dep installation |
|
||||
|
||||
Set uv-compile as default: `comfy manager uv-compile-default true`
|
||||
Make `uv-compile` default: `comfy manager uv-compile-default true`
|
||||
|
||||
---
|
||||
|
||||
## `comfy model` — Model Management
|
||||
|
||||
```bash
|
||||
comfy model list # list all downloaded models
|
||||
comfy model list --relative-path models/checkpoints # specific folder
|
||||
comfy model list
|
||||
comfy model list --relative-path models/checkpoints
|
||||
|
||||
comfy model download --url <URL> # download model
|
||||
comfy model download --url <URL>
|
||||
comfy model download --url <URL> --relative-path models/loras
|
||||
comfy model download --url <URL> --filename custom_name.safetensors
|
||||
|
||||
comfy model remove # interactive removal
|
||||
comfy model remove # interactive
|
||||
comfy model remove --relative-path models/checkpoints --model-names "model.safetensors"
|
||||
```
|
||||
|
||||
@@ -210,24 +183,27 @@ comfy model remove --relative-path models/checkpoints --model-names "model.safet
|
||||
|--------|-------------|
|
||||
| `--url` | Download URL (CivitAI, HuggingFace, direct) |
|
||||
| `--relative-path` | Subdirectory under workspace (e.g. `models/checkpoints`) |
|
||||
| `--filename` | Custom filename to save as |
|
||||
| `--set-civitai-api-token` | Set CivitAI API token |
|
||||
| `--set-hf-api-token` | Set HuggingFace API token |
|
||||
| `--filename` | Custom save filename |
|
||||
| `--set-civitai-api-token` | Persist CivitAI token |
|
||||
| `--set-hf-api-token` | Persist HuggingFace token |
|
||||
| `--downloader` | `httpx` (default) or `aria2` |
|
||||
|
||||
Model directory structure:
|
||||
Standard model directories:
|
||||
```
|
||||
ComfyUI/models/
|
||||
├── checkpoints/ # Full model files (.safetensors, .ckpt)
|
||||
├── loras/ # LoRA adapters
|
||||
├── vae/ # VAE models
|
||||
├── controlnet/ # ControlNet models
|
||||
├── clip/ # CLIP text encoders
|
||||
├── clip_vision/ # CLIP vision encoders
|
||||
├── upscale_models/ # Upscaler models (ESRGAN, etc.)
|
||||
├── embeddings/ # Textual inversion embeddings
|
||||
├── unet/ # UNet models
|
||||
└── diffusion_models/ # Diffusion model files
|
||||
├── checkpoints/ # Full model files
|
||||
├── loras/ # LoRA adapters
|
||||
├── vae/ # VAE models
|
||||
├── controlnet/ # ControlNet models
|
||||
├── clip/ # CLIP / T5 text encoders
|
||||
├── clip_vision/ # CLIP vision encoders
|
||||
├── upscale_models/ # ESRGAN / SwinIR / etc.
|
||||
├── embeddings/ # Textual inversion embeddings
|
||||
├── unet/ # Standalone UNet weights
|
||||
├── diffusion_models/ # Flux / SD3 / Wan diffusion models
|
||||
├── animatediff_models/ # AnimateDiff motion modules
|
||||
├── ipadapter/ # IPAdapter weights
|
||||
└── style_models/ # Style adapters
|
||||
```
|
||||
|
||||
---
|
||||
@@ -235,12 +211,12 @@ ComfyUI/models/
|
||||
## `comfy manager` — ComfyUI-Manager Settings
|
||||
|
||||
```bash
|
||||
comfy manager disable # disable Manager completely
|
||||
comfy manager enable-gui # enable new GUI
|
||||
comfy manager disable-gui # disable GUI (API-only)
|
||||
comfy manager enable-legacy-gui # legacy GUI
|
||||
comfy manager disable # disable Manager completely
|
||||
comfy manager enable-gui # enable new GUI
|
||||
comfy manager disable-gui # API-only
|
||||
comfy manager enable-legacy-gui # legacy GUI
|
||||
comfy manager uv-compile-default true # make --uv-compile the default
|
||||
comfy manager clear # clear startup action
|
||||
comfy manager clear # clear startup action
|
||||
```
|
||||
|
||||
---
|
||||
@@ -248,21 +224,32 @@ comfy manager clear # clear startup action
|
||||
## `comfy pr-cache` — Frontend PR Cache
|
||||
|
||||
```bash
|
||||
comfy pr-cache list # list cached PR builds
|
||||
comfy pr-cache clean # clean all
|
||||
comfy pr-cache clean 456 # clean specific PR
|
||||
comfy pr-cache list
|
||||
comfy pr-cache clean
|
||||
comfy pr-cache clean 456
|
||||
```
|
||||
|
||||
Cache expires after 7 days; max 10 builds kept.
|
||||
Cache expires after 7 days; max 10 builds.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file location:
|
||||
- Linux: `~/.config/comfy-cli/config.ini`
|
||||
- macOS: `~/Library/Application Support/comfy-cli/config.ini`
|
||||
- Windows: `~/AppData/Local/comfy-cli/config.ini`
|
||||
| OS | Path |
|
||||
|----|------|
|
||||
| Linux | `~/.config/comfy-cli/config.ini` |
|
||||
| macOS | `~/Library/Application Support/comfy-cli/config.ini` |
|
||||
| Windows | `~/AppData/Local/comfy-cli/config.ini` |
|
||||
|
||||
Stores: default workspace, recent workspace, background server info, API tokens,
|
||||
manager GUI mode, launch extras.
|
||||
Stores: default workspace, recent workspace, background server PID, API
|
||||
tokens, manager GUI mode, launch extras.
|
||||
|
||||
## Discovery
|
||||
|
||||
Custom-node registry:
|
||||
- https://registry.comfy.org/
|
||||
|
||||
Model browsers:
|
||||
- https://huggingface.co/models
|
||||
- https://civitai.com (NSFW; requires API token for many)
|
||||
- https://comfyworkflows.com (community workflows)
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
# ComfyUI REST API Reference
|
||||
# ComfyUI REST + WebSocket API Reference
|
||||
|
||||
ComfyUI exposes a REST API + WebSocket for workflow execution and management.
|
||||
Same API surface for local servers and Comfy Cloud (with auth differences).
|
||||
ComfyUI exposes a REST + WebSocket interface for workflow execution and
|
||||
management. **The same surface is used locally and on Comfy Cloud, with
|
||||
auth/path differences.**
|
||||
|
||||
## Connection
|
||||
|
||||
| | Local | Cloud |
|
||||
| | Local ComfyUI | Comfy Cloud |
|
||||
|---|---|---|
|
||||
| Base URL | `http://127.0.0.1:8188` | `https://cloud.comfy.org` |
|
||||
| Auth | None (or bearer token) | `X-API-Key` header |
|
||||
| API path prefix | none (`/prompt`, `/view`, …) | `/api/...` (`/api/prompt`, `/api/view`, …) |
|
||||
| Auth | none (or bearer token if configured) | `X-API-Key` header |
|
||||
| WebSocket | `ws://host:port/ws?clientId={uuid}` | `wss://cloud.comfy.org/ws?clientId={uuid}&token={API_KEY}` |
|
||||
| Output download | Direct bytes from `/view` | 302 redirect → signed URL (use `curl -L`) |
|
||||
| `/api/view` response | direct bytes | 302 redirect → signed URL (use `curl -L`) |
|
||||
|
||||
The skill scripts route URLs automatically via `_common.resolve_url()`.
|
||||
|
||||
## Endpoint differences on Comfy Cloud
|
||||
|
||||
The cloud surface diverges from local ComfyUI in several ways. The skill
|
||||
scripts handle these transparently; document them here so anyone calling
|
||||
`curl` directly knows.
|
||||
|
||||
| Local path | Cloud path | Notes |
|
||||
|------------|-----------|-------|
|
||||
| `/system_stats` | `/api/system_stats` | Cloud version is **public** (no auth required) |
|
||||
| `/object_info` | `/api/object_info` | **Paid tier only** — free returns 403 |
|
||||
| `/queue` | `/api/queue` | Paid tier only |
|
||||
| `/userdata` | `/api/userdata` | Paid tier only |
|
||||
| `/prompt` (POST) | `/api/prompt` (POST) | Paid tier only |
|
||||
| `/upload/image` | `/api/upload/image` | Paid tier only; `subfolder` accepted but ignored |
|
||||
| `/upload/mask` | `/api/upload/mask` | Same as above |
|
||||
| `/view` | `/api/view` | Paid tier only; **returns 302** to signed URL |
|
||||
| `/history` | `/api/history_v2` | **Renamed**; old path returns 404 |
|
||||
| `/history/{id}` | `/api/history_v2/{id}` or `/api/jobs/{id}` | Both work; `/jobs` returns full job |
|
||||
| `/models` | `/api/experiment/models` | **Renamed** |
|
||||
| `/models/{folder}` | `/api/experiment/models/{folder}` | **Renamed**; response shape differs (see below) |
|
||||
|
||||
### Cloud model-list response shape
|
||||
|
||||
- **Local:** `["a.safetensors", "b.safetensors", …]` — flat list of strings.
|
||||
- **Cloud:** `[{"name": "a.safetensors", "pathIndex": 0}, …]` — list of objects.
|
||||
- **Cloud 404 with `code: "folder_not_found"`** — folder is empty or unknown,
|
||||
not an "endpoint missing" error. Distinguish by reading the body.
|
||||
|
||||
The skill helper `_common.parse_model_list()` normalizes both.
|
||||
|
||||
## Workflow Execution
|
||||
|
||||
@@ -34,7 +68,8 @@ curl -X POST "https://cloud.comfy.org/api/prompt" \
|
||||
{"prompt_id": "abc-123-def", "number": 1, "node_errors": {}}
|
||||
```
|
||||
|
||||
If `node_errors` is non-empty, the workflow has validation errors (missing nodes, bad inputs).
|
||||
If `node_errors` is non-empty, the workflow has validation errors (missing
|
||||
nodes, bad inputs).
|
||||
|
||||
### Check Job Status (Cloud)
|
||||
|
||||
@@ -43,133 +78,146 @@ curl -X GET "https://cloud.comfy.org/api/job/{prompt_id}/status" \
|
||||
-H "X-API-Key: $COMFY_CLOUD_API_KEY"
|
||||
```
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `pending` | Queued, waiting to start |
|
||||
| `in_progress` | Currently executing |
|
||||
| `completed` | Finished successfully |
|
||||
| `failed` | Encountered an error |
|
||||
| `cancelled` | Cancelled by user |
|
||||
| Status | Description |
|
||||
| ------------- | ---------------------------------- |
|
||||
| `pending` | Job is queued and waiting to start |
|
||||
| `in_progress` | Job is currently executing |
|
||||
| `completed` | Job finished successfully |
|
||||
| `failed` | Job encountered an error |
|
||||
| `cancelled` | Job was cancelled by user |
|
||||
|
||||
### Job detail with outputs (Cloud)
|
||||
|
||||
```bash
|
||||
curl -X GET "https://cloud.comfy.org/api/jobs/{prompt_id}" \
|
||||
-H "X-API-Key: $COMFY_CLOUD_API_KEY"
|
||||
```
|
||||
|
||||
Response includes `outputs` keyed by node ID. Cloud uses `video` (singular)
|
||||
in the output structure; local uses `videos` (plural). The skill scripts
|
||||
accept both.
|
||||
|
||||
### Get History (Local)
|
||||
|
||||
```bash
|
||||
# All history
|
||||
curl -s "http://127.0.0.1:8188/history"
|
||||
|
||||
# Specific prompt
|
||||
curl -s "http://127.0.0.1:8188/history/{prompt_id}"
|
||||
curl -s "http://127.0.0.1:8188/history" # all
|
||||
curl -s "http://127.0.0.1:8188/history/{id}" # one prompt_id
|
||||
```
|
||||
|
||||
Response contains `outputs` keyed by node ID with file references.
|
||||
Local entry shape:
|
||||
```json
|
||||
{
|
||||
"<prompt_id>": {
|
||||
"prompt": [...],
|
||||
"outputs": {"<node_id>": {"images": [...]}},
|
||||
"status": {
|
||||
"status_str": "success" | "error",
|
||||
"completed": true | false,
|
||||
"messages": [["execution_start", {...}], ["execution_error", {...}], …]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** when reading status, check `status_str == "error"` BEFORE
|
||||
checking `completed`, because both can be true for failed runs.
|
||||
|
||||
### Download Output
|
||||
|
||||
```bash
|
||||
# Local
|
||||
# Local (direct bytes)
|
||||
curl -s "http://127.0.0.1:8188/view?filename=ComfyUI_00001_.png&subfolder=&type=output" \
|
||||
-o output.png
|
||||
|
||||
# Cloud (follow redirect)
|
||||
curl -L "https://cloud.comfy.org/api/view?filename=ComfyUI_00001_.png&subfolder=&type=output" \
|
||||
# Cloud (302 → signed URL; -L follows; STRIP X-API-Key for the second hop)
|
||||
curl -L "https://cloud.comfy.org/api/view?filename=...&type=output" \
|
||||
-H "X-API-Key: $COMFY_CLOUD_API_KEY" \
|
||||
-o output.png
|
||||
```
|
||||
|
||||
---
|
||||
The skill's `run_workflow.py` strips `X-API-Key` automatically on the
|
||||
cross-host redirect, so the signed URL never sees your auth.
|
||||
|
||||
## WebSocket Monitoring
|
||||
|
||||
Connect to WebSocket for real-time execution progress.
|
||||
|
||||
### Connection
|
||||
Connect for real-time execution events.
|
||||
|
||||
```bash
|
||||
# Local
|
||||
wscat -c "ws://127.0.0.1:8188/ws?clientId=MY-UUID"
|
||||
|
||||
# Cloud
|
||||
wscat -c "wss://cloud.comfy.org/ws?clientId=MY-UUID&token=API_KEY"
|
||||
wscat -c "wss://cloud.comfy.org/ws?clientId=MY-UUID&token=$COMFY_CLOUD_API_KEY"
|
||||
```
|
||||
|
||||
### Message Types (JSON)
|
||||
**Note:** on Cloud the `clientId` is currently ignored — all messages for a
|
||||
user are broadcast to every connection. Filter messages client-side by
|
||||
`data.prompt_id`.
|
||||
|
||||
### JSON Message Types
|
||||
|
||||
| Type | When | Key Fields |
|
||||
|------|------|------------|
|
||||
| `status` | Queue change | `queue_remaining` |
|
||||
| `status` | Queue change | `status.exec_info.queue_remaining` |
|
||||
| `notification` | User-friendly status string | `value` |
|
||||
| `execution_start` | Workflow begins | `prompt_id` |
|
||||
| `executing` | Node running | `node` (ID), `prompt_id` |
|
||||
| `executing` | Node running (or end-of-run if `node` is null on local) | `node`, `prompt_id` |
|
||||
| `progress` | Sampling steps | `node`, `value`, `max` |
|
||||
| `executed` | Node output ready | `node`, `output` |
|
||||
| `execution_cached` | Nodes skipped | `nodes` (list of IDs) |
|
||||
| `progress_state` | Extended progress with per-node metadata | `nodes` (dict) |
|
||||
| `executed` | Node output ready | `node`, `output` (with `images`/`video`/etc.) |
|
||||
| `execution_cached` | Nodes skipped because of cache | `nodes` (list of IDs) |
|
||||
| `execution_success` | All done | `prompt_id` |
|
||||
| `execution_error` | Failure | `exception_type`, `exception_message`, `traceback` |
|
||||
| `execution_error` | Failure | `exception_type`, `exception_message`, `traceback`, `node_id` |
|
||||
| `execution_interrupted` | Cancelled | `prompt_id` |
|
||||
|
||||
When `executing` has `node: null`, the workflow is complete.
|
||||
### Binary Frames (Preview Images)
|
||||
|
||||
### Binary Messages (Preview Images)
|
||||
| Type code | Meaning |
|
||||
|-----------|---------|
|
||||
| `0x00000001` | `PREVIEW_IMAGE` — `[type:4][image_type:4][data]` (image_type 1=JPEG, 2=PNG) |
|
||||
| `0x00000003` | `TEXT` — `[type:4][nid_len:4][nid][text]` (UTF-8) |
|
||||
| `0x00000004` | `PREVIEW_IMAGE_WITH_METADATA` — `[type:4][meta_len:4][json][image_data]` |
|
||||
|
||||
Format: `[4B type][4B image_type: 1=JPEG, 2=PNG][image_data...]`
|
||||
|
||||
---
|
||||
`scripts/ws_monitor.py --previews <dir>` saves preview frames to disk.
|
||||
|
||||
## File Upload
|
||||
|
||||
### Upload Image
|
||||
|
||||
```bash
|
||||
# Image
|
||||
curl -X POST "http://127.0.0.1:8188/upload/image" \
|
||||
-F "image=@photo.png" \
|
||||
-F "type=input" \
|
||||
-F "overwrite=true"
|
||||
```
|
||||
-F "image=@photo.png" -F "type=input" -F "overwrite=true"
|
||||
# Returns: {"name": "photo.png", "subfolder": "", "type": "input"}
|
||||
|
||||
Response: `{"name": "photo.png", "subfolder": "", "type": "input"}`
|
||||
|
||||
### Upload Mask
|
||||
|
||||
```bash
|
||||
# Mask (linked to a previously uploaded image)
|
||||
curl -X POST "http://127.0.0.1:8188/upload/mask" \
|
||||
-F "image=@mask.png" \
|
||||
-F "type=input" \
|
||||
-F "image=@mask.png" -F "type=input" \
|
||||
-F 'original_ref={"filename":"photo.png","subfolder":"","type":"input"}'
|
||||
```
|
||||
|
||||
---
|
||||
Cloud equivalent: prepend `https://cloud.comfy.org/api` and add `-H "X-API-Key: $COMFY_CLOUD_API_KEY"`.
|
||||
|
||||
## Node & Model Discovery
|
||||
|
||||
### Object Info (All Nodes)
|
||||
|
||||
```bash
|
||||
# All node types and their input specs
|
||||
curl -s "http://127.0.0.1:8188/object_info" | python3 -m json.tool
|
||||
# Returns all node types with input/output definitions
|
||||
|
||||
# Specific node
|
||||
curl -s "http://127.0.0.1:8188/object_info/KSampler"
|
||||
# Returns info for one specific node type
|
||||
```
|
||||
|
||||
### Models by Folder
|
||||
|
||||
```bash
|
||||
# Models per folder (local)
|
||||
curl -s "http://127.0.0.1:8188/models/checkpoints"
|
||||
curl -s "http://127.0.0.1:8188/models/loras"
|
||||
curl -s "http://127.0.0.1:8188/models/vae"
|
||||
curl -s "http://127.0.0.1:8188/models/controlnet"
|
||||
curl -s "http://127.0.0.1:8188/models/clip"
|
||||
curl -s "http://127.0.0.1:8188/models/upscale_models"
|
||||
curl -s "http://127.0.0.1:8188/models/embeddings"
|
||||
|
||||
# Models per folder (cloud — note the experimental prefix)
|
||||
curl -s "https://cloud.comfy.org/api/experiment/models/checkpoints" \
|
||||
-H "X-API-Key: $COMFY_CLOUD_API_KEY"
|
||||
```
|
||||
|
||||
Returns arrays of filenames (relative to model folder).
|
||||
|
||||
---
|
||||
|
||||
## Queue Management
|
||||
|
||||
```bash
|
||||
# View queue (running + pending)
|
||||
# View queue
|
||||
curl -s "http://127.0.0.1:8188/queue"
|
||||
|
||||
# Clear all pending
|
||||
@@ -177,21 +225,19 @@ curl -X POST "http://127.0.0.1:8188/queue" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"clear": true}'
|
||||
|
||||
# Delete specific items from queue
|
||||
# Delete specific items
|
||||
curl -X POST "http://127.0.0.1:8188/queue" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"delete": ["prompt_id_1", "prompt_id_2"]}'
|
||||
|
||||
# Cancel currently running job
|
||||
# Cancel currently-running job
|
||||
curl -X POST "http://127.0.0.1:8188/interrupt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Management
|
||||
|
||||
```bash
|
||||
# System stats (VRAM, RAM, GPU, versions)
|
||||
# Stats (VRAM, RAM, GPU, ComfyUI version)
|
||||
curl -s "http://127.0.0.1:8188/system_stats"
|
||||
|
||||
# Free GPU memory
|
||||
@@ -200,14 +246,13 @@ curl -X POST "http://127.0.0.1:8188/free" \
|
||||
-d '{"unload_models": true, "free_memory": true}'
|
||||
```
|
||||
|
||||
---
|
||||
## ComfyUI-Manager Endpoints (Optional)
|
||||
|
||||
## ComfyUI Manager Endpoints (Optional)
|
||||
|
||||
These require ComfyUI-Manager installed.
|
||||
These require ComfyUI-Manager installed. Useful for installing nodes/models
|
||||
via the API instead of `comfy-cli`.
|
||||
|
||||
```bash
|
||||
# Install custom node from git repo
|
||||
# Install a custom node from a git URL
|
||||
curl -X POST "http://127.0.0.1:8188/manager/queue/install" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"git_url": "https://github.com/user/comfyui-node.git"}'
|
||||
@@ -221,8 +266,6 @@ curl -X POST "http://127.0.0.1:8188/manager/queue/install_model" \
|
||||
-d '{"url": "https://...", "path": "models/checkpoints", "filename": "model.safetensors"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /prompt Payload Format
|
||||
|
||||
```json
|
||||
@@ -246,11 +289,24 @@ curl -X POST "http://127.0.0.1:8188/manager/queue/install_model" \
|
||||
},
|
||||
"client_id": "unique-uuid-for-ws-filtering",
|
||||
"extra_data": {
|
||||
"api_key_comfy_org": "optional-partner-node-key"
|
||||
"api_key_comfy_org": "optional-PARTNER-NODE-key (NOT the cloud auth key)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `prompt`: The workflow graph (API format)
|
||||
- `client_id`: UUID for WebSocket event filtering
|
||||
- `extra_data.api_key_comfy_org`: Required for paid partner nodes (Flux Pro, Ideogram, etc.)
|
||||
- `prompt`: workflow graph in API format
|
||||
- `client_id`: UUID — local server uses it to filter WebSocket events; cloud
|
||||
ignores it.
|
||||
- `extra_data.api_key_comfy_org`: ONLY required when the workflow uses
|
||||
partner nodes (Flux Pro, Ideogram, etc.). Don't conflate with `X-API-Key`.
|
||||
|
||||
## Error Categories (cloud `execution_error` `exception_type`)
|
||||
|
||||
| Type | Meaning |
|
||||
|------|---------|
|
||||
| `ValidationError` | Bad workflow / inputs (often nicer to surface from `node_errors`) |
|
||||
| `ModelDownloadError` | Required model not available |
|
||||
| `ImageDownloadError` | Failed to fetch input image from URL |
|
||||
| `OOMError` | Out of GPU memory |
|
||||
| `InsufficientFundsError` | Account balance too low (partner nodes) |
|
||||
| `InactiveSubscriptionError` | Subscription not active |
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# ComfyUI Workflow JSON Format
|
||||
|
||||
## Two Formats
|
||||
## Two Formats — Only API Format Is Executable
|
||||
|
||||
ComfyUI uses two workflow formats. **Only API format works for programmatic execution.**
|
||||
**API format** is required for `/api/prompt` and every script in this skill.
|
||||
The web UI also produces an "editor format" used for visual editing, which
|
||||
**cannot** be submitted directly.
|
||||
|
||||
### API Format (what we use)
|
||||
### API Format
|
||||
|
||||
Top-level keys are string node IDs. Each node has `class_type` and `inputs`:
|
||||
|
||||
@@ -28,191 +30,197 @@ Top-level keys are string node IDs. Each node has `class_type` and `inputs`:
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {
|
||||
"ckpt_name": "v1-5-pruned-emaonly.safetensors"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a beautiful cat",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "bad quality, ugly",
|
||||
"clip": ["4", 1]
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": ["8", 0]
|
||||
}
|
||||
"inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**How to detect:** Top-level keys are numeric strings, each value has `class_type`.
|
||||
**Detection:** every top-level value has `class_type`. The skill's
|
||||
`_common.is_api_format()` does this check.
|
||||
|
||||
### Editor Format (not directly executable)
|
||||
|
||||
Has `nodes[]` and `links[]` arrays — the visual graph data from the ComfyUI web editor.
|
||||
This is what "Save" produces. For API use, export with "Save (API Format)" instead.
|
||||
Has `nodes[]` and `links[]` arrays — the visual graph. To convert: open in
|
||||
ComfyUI's web UI and use **Workflow → Export (API)** (newer UI) or the
|
||||
"Save (API Format)" button (older UI).
|
||||
|
||||
**How to detect:** Top-level has `"nodes"` and `"links"` keys.
|
||||
**Detection:** top-level has `"nodes"` and `"links"` keys.
|
||||
|
||||
---
|
||||
## Inputs: Literals vs Links
|
||||
|
||||
## Input Connections
|
||||
```json
|
||||
"inputs": {
|
||||
"text": "a cat", // literal — modifiable
|
||||
"seed": 42, // literal — modifiable
|
||||
"clip": ["4", 1] // link — wiring; do NOT overwrite
|
||||
}
|
||||
```
|
||||
|
||||
Inputs can be:
|
||||
- **Literal values**: `"text": "a cat"`, `"seed": 42`, `"width": 512`
|
||||
- **Links to other nodes**: `["node_id", output_index]` — e.g., `["4", 0]` means
|
||||
output slot 0 of node "4"
|
||||
|
||||
Only literal values can be modified by parameter injection. Linked inputs are wiring.
|
||||
|
||||
---
|
||||
Links are length-2 arrays of `[upstream_node_id, output_slot]`. The skill's
|
||||
parameter injector refuses to overwrite a link with a literal (logs a
|
||||
warning and skips).
|
||||
|
||||
## Common Node Types and Their Controllable Parameters
|
||||
|
||||
The full catalog lives in `scripts/_common.py` (`PARAM_PATTERNS` and
|
||||
`MODEL_LOADERS`). Highlights:
|
||||
|
||||
### Text Prompts
|
||||
|
||||
| Node Class | Key Fields |
|
||||
|------------|-----------|
|
||||
| `CLIPTextEncode` | `text` (the prompt string) |
|
||||
|------------|------------|
|
||||
| `CLIPTextEncode` | `text` |
|
||||
| `CLIPTextEncodeSDXL` | `text_g`, `text_l`, `width`, `height` |
|
||||
| `CLIPTextEncodeFlux` | `clip_l`, `t5xxl`, `guidance` |
|
||||
|
||||
Usually: positive prompt → one CLIPTextEncode, negative prompt → another.
|
||||
Distinguish by checking the `_meta.title` field or by tracing which feeds into
|
||||
positive vs negative inputs of the sampler.
|
||||
To distinguish positive from negative the skill traces `KSampler.negative`
|
||||
back through Reroute / Primitive nodes to the source CLIPTextEncode. Falls
|
||||
back to `_meta.title` heuristics ("negative", "neg", "anti").
|
||||
|
||||
### Sampling
|
||||
|
||||
| Node Class | Key Fields |
|
||||
|------------|-----------|
|
||||
|------------|------------|
|
||||
| `KSampler` | `seed`, `steps`, `cfg`, `sampler_name`, `scheduler`, `denoise` |
|
||||
| `KSamplerAdvanced` | `noise_seed`, `steps`, `cfg`, `sampler_name`, `scheduler`, `start_at_step`, `end_at_step` |
|
||||
| `SamplerCustom` | `cfg`, `sampler`, `sigmas` |
|
||||
| `KSamplerAdvanced` | `noise_seed`, `steps`, `cfg`, `start_at_step`, `end_at_step` |
|
||||
| `SamplerCustom` | `noise_seed`, `cfg`, `sampler`, `sigmas` |
|
||||
| `SamplerCustomAdvanced` | `noise_seed` (via RandomNoise input) |
|
||||
| `RandomNoise` | `noise_seed` |
|
||||
| `BasicScheduler` | `steps`, `scheduler`, `denoise` |
|
||||
| `KSamplerSelect` | `sampler_name` |
|
||||
| `BasicGuider` / `CFGGuider` | `cfg` |
|
||||
| `ModelSamplingFlux` | `max_shift`, `base_shift`, `width`, `height` |
|
||||
| `SDTurboScheduler` | `steps`, `denoise` |
|
||||
|
||||
### Image Dimensions
|
||||
### Latent / Dimensions
|
||||
|
||||
| Node Class | Key Fields |
|
||||
|------------|-----------|
|
||||
|------------|------------|
|
||||
| `EmptyLatentImage` | `width`, `height`, `batch_size` |
|
||||
| `LatentUpscale` | `width`, `height`, `upscale_method` |
|
||||
| `EmptySD3LatentImage` | `width`, `height`, `batch_size` |
|
||||
| `EmptyHunyuanLatentVideo` | `width`, `height`, `length`, `batch_size` |
|
||||
| `EmptyMochiLatentVideo` | `width`, `height`, `length`, `batch_size` |
|
||||
| `EmptyLTXVLatentVideo` | `width`, `height`, `length`, `batch_size` |
|
||||
|
||||
### Model Loading
|
||||
|
||||
| Node Class | Key Fields | Model Folder |
|
||||
|------------|-----------|-------------|
|
||||
| Node Class | Key Fields | Folder |
|
||||
|------------|------------|--------|
|
||||
| `CheckpointLoaderSimple` | `ckpt_name` | `checkpoints` |
|
||||
| `LoraLoader` | `lora_name`, `strength_model`, `strength_clip` | `loras` |
|
||||
| `LoraLoaderModelOnly` | `lora_name`, `strength_model` | `loras` |
|
||||
| `VAELoader` | `vae_name` | `vae` |
|
||||
| `ControlNetLoader` | `control_net_name` | `controlnet` |
|
||||
| `CLIPLoader` | `clip_name` | `clip` |
|
||||
| `DualCLIPLoader` | `clip_name1`, `clip_name2` | `clip` |
|
||||
| `TripleCLIPLoader` | `clip_name1/2/3` | `clip` |
|
||||
| `UNETLoader` | `unet_name` | `unet` |
|
||||
| `DiffusionModelLoader` | `model_name` | `diffusion_models` |
|
||||
| `UpscaleModelLoader` | `model_name` | `upscale_models` |
|
||||
| `IPAdapterModelLoader` | `ipadapter_file` | `ipadapter` |
|
||||
| `ADE_AnimateDiffLoaderWithContext` | `model_name`, `motion_scale` | `animatediff_models` |
|
||||
|
||||
### Image Input/Output
|
||||
|
||||
| Node Class | Key Fields |
|
||||
|------------|-----------|
|
||||
| `LoadImage` | `image` (filename on server, after upload) |
|
||||
| `LoadImageMask` | `image`, `channel` |
|
||||
|------------|------------|
|
||||
| `LoadImage` | `image` (server-side filename, after upload) |
|
||||
| `LoadImageMask` | `image`, `channel` (`red` / `green` / `blue` / `alpha`) |
|
||||
| `VAEEncode` / `VAEDecode` | (no controllable fields) |
|
||||
| `VAEEncodeForInpaint` | `grow_mask_by` |
|
||||
| `SaveImage` | `filename_prefix` |
|
||||
| `PreviewImage` | (no controllable fields, just previews) |
|
||||
| `VHS_VideoCombine` | `frame_rate`, `format`, `filename_prefix`, `loop_count`, `pingpong` |
|
||||
|
||||
### ControlNet
|
||||
|
||||
| Node Class | Key Fields |
|
||||
|------------|-----------|
|
||||
|------------|------------|
|
||||
| `ControlNetApply` | `strength` |
|
||||
| `ControlNetApplyAdvanced` | `strength`, `start_percent`, `end_percent` |
|
||||
|
||||
### Video (AnimateDiff)
|
||||
### IPAdapter (community pack `comfyui_ipadapter_plus`)
|
||||
|
||||
| Node Class | Key Fields |
|
||||
|------------|-----------|
|
||||
| `ADE_AnimateDiffLoaderWithContext` | `model_name`, `motion_scale` |
|
||||
| `VHS_VideoCombine` | `frame_rate`, `format`, `filename_prefix` |
|
||||
|------------|------------|
|
||||
| `IPAdapterAdvanced` | `weight`, `start_at`, `end_at` |
|
||||
| `IPAdapter` | `weight` |
|
||||
|
||||
---
|
||||
### Embeddings (referenced inside prompt strings)
|
||||
|
||||
ComfyUI scans prompt text for `embedding:NAME` syntax. The skill's
|
||||
`_common.iter_embedding_refs()` extracts these as model dependencies.
|
||||
|
||||
```text
|
||||
"a beautiful cat, embedding:goodvibes:1.2, embedding:art-style"
|
||||
```
|
||||
|
||||
`extract_schema.py` and `check_deps.py` surface these in
|
||||
`embedding_dependencies` / `missing_embeddings`.
|
||||
|
||||
## Parameter Injection Pattern
|
||||
|
||||
To modify a workflow programmatically:
|
||||
|
||||
```python
|
||||
import json, copy
|
||||
|
||||
with open("workflow_api.json") as f:
|
||||
workflow = json.load(f)
|
||||
|
||||
# Deep copy to avoid mutating original
|
||||
wf = copy.deepcopy(workflow)
|
||||
|
||||
# Inject parameters by node ID + field name
|
||||
wf["6"]["inputs"]["text"] = "a beautiful sunset" # positive prompt
|
||||
wf["7"]["inputs"]["text"] = "ugly, blurry" # negative prompt
|
||||
wf["3"]["inputs"]["seed"] = 42 # seed
|
||||
wf["3"]["inputs"]["steps"] = 30 # steps
|
||||
wf["5"]["inputs"]["width"] = 1024 # width
|
||||
wf["5"]["inputs"]["height"] = 1024 # height
|
||||
wf["6"]["inputs"]["text"] = "a beautiful sunset"
|
||||
wf["7"]["inputs"]["text"] = "ugly, blurry"
|
||||
wf["3"]["inputs"]["seed"] = 42
|
||||
wf["3"]["inputs"]["steps"] = 30
|
||||
wf["5"]["inputs"]["width"] = 1024
|
||||
wf["5"]["inputs"]["height"] = 1024
|
||||
```
|
||||
|
||||
The `scripts/extract_schema.py` in this skill automates discovering which
|
||||
node IDs and fields correspond to which user-facing parameters.
|
||||
|
||||
---
|
||||
`scripts/extract_schema.py` automates discovering which node IDs/fields
|
||||
correspond to which user-facing parameters. It returns a `parameters` dict
|
||||
that `run_workflow.py` reads to inject values from `--args`.
|
||||
|
||||
## Identifying Controllable Parameters (Heuristics)
|
||||
|
||||
When analyzing an unknown workflow, these patterns identify user-facing params:
|
||||
For unknown workflows:
|
||||
|
||||
1. **Prompt text**: Any `CLIPTextEncode` → `text` field. Title/meta usually
|
||||
indicates positive vs negative.
|
||||
|
||||
2. **Seed**: Any `KSampler` / `KSamplerAdvanced` → `seed` / `noise_seed`.
|
||||
Randomizable — set to different values for variations.
|
||||
|
||||
3. **Dimensions**: `EmptyLatentImage` → `width`, `height`. Common: 512, 768,
|
||||
1024 (must be multiples of 8).
|
||||
|
||||
4. **Steps**: `KSampler` → `steps`. More = higher quality + slower. 20-50 typical.
|
||||
|
||||
5. **CFG scale**: `KSampler` → `cfg`. How closely to follow prompt. 5-15 typical.
|
||||
|
||||
6. **Model/checkpoint**: `CheckpointLoaderSimple` → `ckpt_name`. Must match an
|
||||
installed model filename exactly.
|
||||
|
||||
7. **LoRA**: `LoraLoader` → `lora_name`, `strength_model`. Adapter name + weight.
|
||||
|
||||
8. **Images for img2img**: `LoadImage` → `image`. Filename on server after upload.
|
||||
|
||||
9. **Denoise strength**: `KSampler` → `denoise`. 0.0-1.0. Lower = closer to input
|
||||
image. Only relevant for img2img.
|
||||
|
||||
---
|
||||
1. **Prompt text** — any `CLIPTextEncode.text`. Use connection tracing back
|
||||
from `KSampler.positive` / `.negative` to disambiguate (don't trust
|
||||
meta-title alone).
|
||||
2. **Seed** — `KSampler.seed` / `KSamplerAdvanced.noise_seed` / `RandomNoise.noise_seed`.
|
||||
3. **Dimensions** — `Empty*LatentImage.width/height` (must be multiples of 8).
|
||||
4. **Steps / CFG** — `KSampler.steps`, `KSampler.cfg`. Steps 20–50 typical.
|
||||
CFG 5–15 typical (Flux uses guidance, not CFG).
|
||||
5. **Model / checkpoint** — `CheckpointLoaderSimple.ckpt_name`. Filename must
|
||||
match an installed file *exactly*.
|
||||
6. **LoRA** — `LoraLoader.lora_name`, `.strength_model`.
|
||||
7. **Images for img2img / inpaint** — `LoadImage.image`. Server-side filename
|
||||
after upload.
|
||||
8. **Denoise** — `KSampler.denoise`. 0.0–1.0; 1.0 = ignore input image,
|
||||
0.0 = pass through. Sweet spot for img2img: 0.4–0.7.
|
||||
|
||||
## Output Nodes
|
||||
|
||||
Output is produced by these node types:
|
||||
Output is produced by these node types. The skill's `OUTPUT_NODES` set
|
||||
extends to common community packs.
|
||||
|
||||
| Node | Output Key | Content |
|
||||
|------|-----------|---------|
|
||||
| `SaveImage` | `images` | List of `{filename, subfolder, type}` |
|
||||
| `VHS_VideoCombine` | `gifs` or `videos` | Video file references |
|
||||
| `SaveAudio` | `audio` | Audio file references |
|
||||
| `PreviewImage` | `images` | Temporary preview (not saved) |
|
||||
| `VHS_VideoCombine` | `gifs` (older) or `videos`/`video` (newer cloud) | Video file refs |
|
||||
| `SaveAudio` | `audio` | Audio file refs |
|
||||
| `SaveAnimatedWEBP` / `SaveAnimatedPNG` | `images` | Animated images |
|
||||
| `Save3D` | `3d` | 3D asset refs |
|
||||
|
||||
After execution, fetch outputs from `/history/{prompt_id}` → `outputs` → `{node_id}`.
|
||||
After execution, fetch outputs from `/history/{prompt_id}` (local) or
|
||||
`/api/jobs/{prompt_id}` (cloud) → `outputs` → `{node_id}` → `{key}`.
|
||||
|
||||
## Wrapper Variants
|
||||
|
||||
Some saved JSON files wrap the workflow under a `"prompt"` key (matching
|
||||
the `/api/prompt` payload shape). The skill's `_common.unwrap_workflow()`
|
||||
handles this — pass any of:
|
||||
|
||||
- raw API format: `{"3": {...}, "4": {...}}`
|
||||
- wrapped: `{"prompt": {"3": {...}}, "client_id": "..."}`
|
||||
|
||||
It rejects editor format with a clear error and a re-export instruction.
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
"""
|
||||
_common.py — Shared logic for ComfyUI skill scripts.
|
||||
|
||||
Single source of truth for:
|
||||
- HTTP transport (with retry/backoff, streaming, timeout handling)
|
||||
- Cloud detection and endpoint mapping (local ComfyUI vs Comfy Cloud)
|
||||
- Workflow node-type catalogs (param patterns, model loaders, output nodes)
|
||||
- API-format validation
|
||||
- Path-traversal-safe file writes
|
||||
- API-key loading from env / CLI
|
||||
|
||||
Stdlib-only by design (with optional `requests` upgrade if installed). Python 3.10+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Optional: prefer `requests` if installed (better redirects, streaming, header handling)
|
||||
try:
|
||||
import requests # type: ignore[import-not-found]
|
||||
HAS_REQUESTS = True
|
||||
except ImportError: # pragma: no cover - exercised via stdlib fallback
|
||||
HAS_REQUESTS = False
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants & catalogs
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_LOCAL_HOST = "http://127.0.0.1:8188"
|
||||
DEFAULT_CLOUD_HOST = "https://cloud.comfy.org"
|
||||
ENV_API_KEY = "COMFY_CLOUD_API_KEY"
|
||||
|
||||
# Connection / retry defaults
|
||||
DEFAULT_HTTP_TIMEOUT = 60 # seconds — single-attempt request timeout
|
||||
DEFAULT_RETRIES = 3 # total attempts including the first
|
||||
RETRY_BASE_DELAY = 1.0 # seconds — exponential backoff base
|
||||
RETRY_MAX_DELAY = 30.0 # seconds — cap on backoff
|
||||
RETRY_STATUS_CODES = {408, 429, 500, 502, 503, 504, 522, 524}
|
||||
|
||||
# Streaming download chunk size (bytes)
|
||||
DOWNLOAD_CHUNK_SIZE = 1 << 16 # 64 KiB
|
||||
|
||||
# Heuristic: workflows with these node types tend to be slow → larger default timeout
|
||||
SLOW_OUTPUT_NODES = {
|
||||
"VHS_VideoCombine", "SaveAnimatedWEBP", "SaveAnimatedPNG",
|
||||
"SaveVideo", "SaveAudio", "SaveAnimateDiffVideo",
|
||||
"SVD_img2vid_Conditioning",
|
||||
"WanVideoSampler", "HunyuanVideoSampler",
|
||||
"CogVideoSampler", "LTXVideoSampler",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output node catalog (extensible — community packs add their own)
|
||||
# ---------------------------------------------------------------------------
|
||||
OUTPUT_NODES: set[str] = {
|
||||
# Built-in
|
||||
"SaveImage", "PreviewImage",
|
||||
"SaveAudio", "SaveVideo", "PreviewAudio", "PreviewVideo",
|
||||
"SaveAnimatedWEBP", "SaveAnimatedPNG",
|
||||
# Common community packs
|
||||
"VHS_VideoCombine", # Video Helper Suite
|
||||
"ImageSave", # Was Node Suite
|
||||
"Image Save", # Was Node Suite (alt name)
|
||||
"easy imageSave", # easy-use
|
||||
"Image Save With Metadata",
|
||||
"PreviewImage|pysssss", # pysssss preview
|
||||
"ShowText|pysssss",
|
||||
"SaveLatent",
|
||||
"SaveGLB", # 3D
|
||||
"Save3D",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Folder aliases — handle ComfyUI's gradual folder renames
|
||||
# ---------------------------------------------------------------------------
|
||||
# When `check_deps.py` queries `/models/<folder>` and gets 404 / empty,
|
||||
# it tries each alias in turn. Critical for Comfy Cloud which has fully
|
||||
# migrated to the new naming (unet → diffusion_models, clip → text_encoders).
|
||||
FOLDER_ALIASES: dict[str, list[str]] = {
|
||||
"unet": ["unet", "diffusion_models"],
|
||||
"diffusion_models": ["diffusion_models", "unet"],
|
||||
"clip": ["clip", "text_encoders"],
|
||||
"text_encoders": ["text_encoders", "clip"],
|
||||
"controlnet": ["controlnet", "control_net"],
|
||||
}
|
||||
|
||||
|
||||
def folder_aliases_for(folder: str) -> list[str]:
|
||||
"""Return the search order of folder names (primary first)."""
|
||||
return FOLDER_ALIASES.get(folder, [folder])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model-loader catalog: class_type -> (input field, model folder)
|
||||
# ---------------------------------------------------------------------------
|
||||
# A loader can have multiple fields (e.g., DualCLIPLoader has clip_name1 and
|
||||
# clip_name2). We list them with explicit entries. The folder name is the
|
||||
# *canonical* one; FOLDER_ALIASES is consulted when querying.
|
||||
MODEL_LOADERS: dict[str, list[tuple[str, str]]] = {
|
||||
# Checkpoints
|
||||
"CheckpointLoaderSimple": [("ckpt_name", "checkpoints")],
|
||||
"CheckpointLoader": [("ckpt_name", "checkpoints")],
|
||||
"CheckpointLoader (Simple)": [("ckpt_name", "checkpoints")],
|
||||
"ImageOnlyCheckpointLoader": [("ckpt_name", "checkpoints")],
|
||||
"unCLIPCheckpointLoader": [("ckpt_name", "checkpoints")],
|
||||
# LoRA
|
||||
"LoraLoader": [("lora_name", "loras")],
|
||||
"LoraLoaderModelOnly": [("lora_name", "loras")],
|
||||
"LoraLoaderTagsQuery": [("lora_name", "loras")],
|
||||
# VAE
|
||||
"VAELoader": [("vae_name", "vae")],
|
||||
# ControlNet
|
||||
"ControlNetLoader": [("control_net_name", "controlnet")],
|
||||
"DiffControlNetLoader": [("control_net_name", "controlnet")],
|
||||
"ControlNetLoaderAdvanced": [("control_net_name", "controlnet")],
|
||||
# CLIP / text encoders (primary "clip" folder; check_deps tries text_encoders too)
|
||||
"CLIPLoader": [("clip_name", "clip")],
|
||||
"DualCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip")],
|
||||
"TripleCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip"), ("clip_name3", "clip")],
|
||||
"CLIPVisionLoader": [("clip_name", "clip_vision")],
|
||||
# UNET / Diffusion model (primary "unet"; check_deps tries diffusion_models too)
|
||||
"UNETLoader": [("unet_name", "unet")],
|
||||
"DiffusionModelLoader": [("model_name", "diffusion_models")],
|
||||
"UNETLoaderGGUF": [("unet_name", "unet")],
|
||||
# Upscaler
|
||||
"UpscaleModelLoader": [("model_name", "upscale_models")],
|
||||
# Style / GLIGEN / Hypernetwork
|
||||
"StyleModelLoader": [("style_model_name", "style_models")],
|
||||
"GLIGENLoader": [("gligen_name", "gligen")],
|
||||
"HypernetworkLoader": [("hypernetwork_name", "hypernetworks")],
|
||||
# IPAdapter family (community).
|
||||
# Note: IPAdapterUnifiedLoader's `preset` and IPAdapterInsightFaceLoader's
|
||||
# `provider` are enums (not file paths), so they're intentionally omitted —
|
||||
# check_deps would otherwise treat enum values as missing model files.
|
||||
"IPAdapterModelLoader": [("ipadapter_file", "ipadapter")],
|
||||
"InstantIDModelLoader": [("instantid_file", "instantid")],
|
||||
# AnimateDiff / video
|
||||
"ADE_LoadAnimateDiffModel": [("model_name", "animatediff_models")],
|
||||
"ADE_AnimateDiffLoaderWithContext": [("model_name", "animatediff_models")],
|
||||
"ADE_AnimateDiffLoaderGen1": [("model_name", "animatediff_models")],
|
||||
# Photomaker
|
||||
"PhotoMakerLoader": [("photomaker_model_name", "photomaker")],
|
||||
# Sampler / scheduler models
|
||||
"ModelSamplingFlux": [], # parametric only
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Param patterns: (class_type, field_name) -> friendly_name
|
||||
# Order matters — first match wins for naming. Use _meta.title for disambiguation.
|
||||
# ---------------------------------------------------------------------------
|
||||
PARAM_PATTERNS: list[tuple[str, str, str]] = [
|
||||
# ---- Prompts ----
|
||||
("CLIPTextEncode", "text", "prompt"),
|
||||
("CLIPTextEncodeSDXL", "text_g", "prompt"),
|
||||
("CLIPTextEncodeSDXL", "text_l", "prompt_l"),
|
||||
("CLIPTextEncodeSDXLRefiner", "text", "refiner_prompt"),
|
||||
("CLIPTextEncodeFlux", "clip_l", "prompt_l"),
|
||||
("CLIPTextEncodeFlux", "t5xxl", "prompt"),
|
||||
("CLIPTextEncodeFlux", "guidance", "guidance"),
|
||||
("smZ CLIPTextEncode", "text", "prompt"),
|
||||
("BNK_CLIPTextEncodeAdvanced", "text", "prompt"),
|
||||
|
||||
# ---- Standard sampling ----
|
||||
("KSampler", "seed", "seed"),
|
||||
("KSampler", "steps", "steps"),
|
||||
("KSampler", "cfg", "cfg"),
|
||||
("KSampler", "sampler_name", "sampler_name"),
|
||||
("KSampler", "scheduler", "scheduler"),
|
||||
("KSampler", "denoise", "denoise"),
|
||||
("KSamplerAdvanced", "noise_seed", "seed"),
|
||||
("KSamplerAdvanced", "steps", "steps"),
|
||||
("KSamplerAdvanced", "cfg", "cfg"),
|
||||
("KSamplerAdvanced", "sampler_name", "sampler_name"),
|
||||
("KSamplerAdvanced", "scheduler", "scheduler"),
|
||||
("KSamplerAdvanced", "start_at_step", "start_at_step"),
|
||||
("KSamplerAdvanced", "end_at_step", "end_at_step"),
|
||||
|
||||
# ---- Modern sampler chain (Flux / SD3 / SDXL refiner via SamplerCustom) ----
|
||||
("RandomNoise", "noise_seed", "seed"),
|
||||
("BasicScheduler", "steps", "steps"),
|
||||
("BasicScheduler", "scheduler", "scheduler"),
|
||||
("BasicScheduler", "denoise", "denoise"),
|
||||
("KSamplerSelect", "sampler_name", "sampler_name"),
|
||||
# NB: BasicGuider has no cfg input (it just bundles model+conditioning).
|
||||
("CFGGuider", "cfg", "cfg"),
|
||||
("DualCFGGuider", "cfg_conds", "cfg"),
|
||||
("DualCFGGuider", "cfg_cond2_negative", "cfg_negative"),
|
||||
("ModelSamplingFlux", "max_shift", "max_shift"),
|
||||
("ModelSamplingFlux", "base_shift", "base_shift"),
|
||||
("ModelSamplingFlux", "width", "model_width"),
|
||||
("ModelSamplingFlux", "height", "model_height"),
|
||||
("ModelSamplingSD3", "shift", "shift"),
|
||||
("ModelSamplingDiscrete", "sampling", "sampling"),
|
||||
("SDTurboScheduler", "steps", "steps"),
|
||||
("SDTurboScheduler", "denoise", "denoise"),
|
||||
("SamplerCustom", "noise_seed", "seed"),
|
||||
("SamplerCustom", "cfg", "cfg"),
|
||||
# NB: SamplerCustomAdvanced takes a NOISE input (from RandomNoise) — no seed field directly.
|
||||
|
||||
# ---- Dimensions / latent ----
|
||||
("EmptyLatentImage", "width", "width"),
|
||||
("EmptyLatentImage", "height", "height"),
|
||||
("EmptyLatentImage", "batch_size", "batch_size"),
|
||||
("EmptySD3LatentImage", "width", "width"),
|
||||
("EmptySD3LatentImage", "height", "height"),
|
||||
("EmptySD3LatentImage", "batch_size", "batch_size"),
|
||||
("EmptyHunyuanLatentVideo", "width", "width"),
|
||||
("EmptyHunyuanLatentVideo", "height", "height"),
|
||||
("EmptyHunyuanLatentVideo", "length", "length"),
|
||||
("EmptyHunyuanLatentVideo", "batch_size", "batch_size"),
|
||||
("EmptyMochiLatentVideo", "width", "width"),
|
||||
("EmptyMochiLatentVideo", "height", "height"),
|
||||
("EmptyMochiLatentVideo", "length", "length"),
|
||||
("EmptyLTXVLatentVideo", "width", "width"),
|
||||
("EmptyLTXVLatentVideo", "height", "height"),
|
||||
("EmptyLTXVLatentVideo", "length", "length"),
|
||||
("LatentUpscale", "width", "upscale_width"),
|
||||
("LatentUpscale", "height", "upscale_height"),
|
||||
("LatentUpscaleBy", "scale_by", "scale_by"),
|
||||
("ImageScale", "width", "width"),
|
||||
("ImageScale", "height", "height"),
|
||||
|
||||
# ---- Image input ----
|
||||
("LoadImage", "image", "image"),
|
||||
("LoadImageMask", "image", "mask_image"),
|
||||
("LoadImageOutput", "image", "image"),
|
||||
("VHS_LoadVideo", "video", "video"),
|
||||
("VHS_LoadAudio", "audio", "audio"),
|
||||
|
||||
# ---- Model selection (sometimes useful to swap per run) ----
|
||||
("CheckpointLoaderSimple", "ckpt_name", "ckpt_name"),
|
||||
("CheckpointLoader", "ckpt_name", "ckpt_name"),
|
||||
("ImageOnlyCheckpointLoader", "ckpt_name", "ckpt_name"),
|
||||
("VAELoader", "vae_name", "vae_name"),
|
||||
("UNETLoader", "unet_name", "unet_name"),
|
||||
("DiffusionModelLoader", "model_name", "diffusion_model_name"),
|
||||
("UpscaleModelLoader", "model_name", "upscale_model_name"),
|
||||
("CLIPLoader", "clip_name", "clip_name"),
|
||||
("DualCLIPLoader", "clip_name1", "clip_name1"),
|
||||
("DualCLIPLoader", "clip_name2", "clip_name2"),
|
||||
("ControlNetLoader", "control_net_name", "controlnet_name"),
|
||||
|
||||
# ---- LoRA ----
|
||||
("LoraLoader", "lora_name", "lora_name"),
|
||||
("LoraLoader", "strength_model", "lora_strength"),
|
||||
("LoraLoader", "strength_clip", "lora_strength_clip"),
|
||||
("LoraLoaderModelOnly", "lora_name", "lora_name"),
|
||||
("LoraLoaderModelOnly", "strength_model", "lora_strength"),
|
||||
|
||||
# ---- ControlNet ----
|
||||
("ControlNetApply", "strength", "controlnet_strength"),
|
||||
("ControlNetApplyAdvanced", "strength", "controlnet_strength"),
|
||||
("ControlNetApplyAdvanced", "start_percent", "controlnet_start"),
|
||||
("ControlNetApplyAdvanced", "end_percent", "controlnet_end"),
|
||||
|
||||
# ---- IPAdapter ----
|
||||
("IPAdapterAdvanced", "weight", "ipadapter_weight"),
|
||||
("IPAdapterAdvanced", "start_at", "ipadapter_start"),
|
||||
("IPAdapterAdvanced", "end_at", "ipadapter_end"),
|
||||
("IPAdapter", "weight", "ipadapter_weight"),
|
||||
|
||||
# ---- Upscale ----
|
||||
("ImageUpscaleWithModel", "upscale_method", "upscale_method"),
|
||||
|
||||
# ---- AnimateDiff ----
|
||||
("ADE_AnimateDiffLoaderWithContext", "motion_scale", "motion_scale"),
|
||||
("ADE_AnimateDiffLoaderGen1", "motion_scale", "motion_scale"),
|
||||
|
||||
# ---- Video / Save ----
|
||||
("VHS_VideoCombine", "frame_rate", "frame_rate"),
|
||||
("VHS_VideoCombine", "format", "video_format"),
|
||||
("VHS_VideoCombine", "filename_prefix", "filename_prefix"),
|
||||
("SaveImage", "filename_prefix", "filename_prefix"),
|
||||
|
||||
# ---- Hunyuan / Wan / LTX video ----
|
||||
("HunyuanVideoSampler", "seed", "seed"),
|
||||
("HunyuanVideoSampler", "steps", "steps"),
|
||||
("HunyuanVideoSampler", "cfg", "cfg"),
|
||||
("WanVideoSampler", "seed", "seed"),
|
||||
("WanVideoSampler", "steps", "steps"),
|
||||
("WanVideoSampler", "cfg", "cfg"),
|
||||
("LTXVScheduler", "max_shift", "max_shift"),
|
||||
("LTXVScheduler", "base_shift", "base_shift"),
|
||||
|
||||
# ---- rgthree primitives (often used as user-facing inputs) ----
|
||||
("Seed (rgthree)", "seed", "seed"),
|
||||
("Image Comparer (rgthree)", "image_a", "image"),
|
||||
("Power Lora Loader (rgthree)", "PowerLoraLoaderHeaderWidget", "_lora_header"),
|
||||
|
||||
# ---- Easy-use / utility primitives ----
|
||||
("PrimitiveNode", "value", "primitive_value"),
|
||||
("easy seed", "seed", "seed"),
|
||||
("easy positive", "positive", "prompt"),
|
||||
("easy negative", "negative", "negative_prompt"),
|
||||
("easy fullLoader", "ckpt_name", "ckpt_name"),
|
||||
("easy fullLoader", "vae_name", "vae_name"),
|
||||
("easy fullLoader", "lora_name", "lora_name"),
|
||||
("easy fullLoader", "positive", "prompt"),
|
||||
("easy fullLoader", "negative", "negative_prompt"),
|
||||
]
|
||||
|
||||
# Prompt-like fields whose value should be scanned for embedding references
|
||||
PROMPT_FIELDS = {"text", "text_g", "text_l", "t5xxl", "clip_l", "positive", "negative"}
|
||||
|
||||
# Pattern matches: embedding:name, embedding:name.pt, embedding:name:1.2, (embedding:name:1.2)
|
||||
# Word-boundary at start avoids matching things like "no_embedding:foo".
|
||||
EMBEDDING_REGEX = re.compile(
|
||||
r"(?:^|[\s,(\[])embedding\s*:\s*([A-Za-z0-9_\-\./\\]+?)(?:\.(?:pt|safetensors|bin))?(?=[\s:,)\(\]]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cloud detection & endpoint routing
|
||||
# =============================================================================
|
||||
|
||||
CLOUD_DOMAIN_SUFFIXES = (".comfy.org",)
|
||||
CLOUD_DOMAIN_EXACT = {"cloud.comfy.org"}
|
||||
|
||||
|
||||
def is_cloud_host(host: str) -> bool:
|
||||
"""True if the host points at Comfy Cloud (or staging/preview subdomain)."""
|
||||
parsed = urlparse(host if "://" in host else f"http://{host}")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if hostname in CLOUD_DOMAIN_EXACT:
|
||||
return True
|
||||
return any(hostname.endswith(s) for s in CLOUD_DOMAIN_SUFFIXES)
|
||||
|
||||
|
||||
def build_cloud_aware_url(base: str, path: str, *, force_cloud: bool | None = None) -> str:
|
||||
"""Build a URL that adds /api prefix when targeting Comfy Cloud.
|
||||
|
||||
Local ComfyUI accepts both `/foo` and `/api/foo` for many endpoints.
|
||||
Cloud requires `/api/foo`.
|
||||
|
||||
`path` should be a path component (e.g. "/prompt") or full path with query
|
||||
(e.g. "/view?filename=x").
|
||||
"""
|
||||
base = base.rstrip("/")
|
||||
cloud = is_cloud_host(base) if force_cloud is None else force_cloud
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if cloud and not path.startswith("/api/"):
|
||||
path = "/api" + path
|
||||
return base + path
|
||||
|
||||
|
||||
def cloud_endpoint(path: str) -> str:
|
||||
"""Map a cloud endpoint path to its current canonical form.
|
||||
|
||||
Handles known renames documented in the Comfy Cloud API:
|
||||
/history -> /history_v2
|
||||
/models/<f> -> /experiment/models/<f>
|
||||
/models -> /experiment/models
|
||||
"""
|
||||
if path.startswith("/history") and not path.startswith("/history_v2"):
|
||||
return "/history_v2" + path[len("/history"):]
|
||||
if path.startswith("/models/"):
|
||||
return "/experiment/models/" + path[len("/models/"):]
|
||||
if path == "/models":
|
||||
return "/experiment/models"
|
||||
return path
|
||||
|
||||
|
||||
def resolve_url(base: str, path: str, *, is_cloud: bool | None = None) -> str:
|
||||
"""Top-level URL resolver. Applies cloud rename + /api prefix as needed."""
|
||||
cloud = is_cloud_host(base) if is_cloud is None else is_cloud
|
||||
if cloud:
|
||||
path = cloud_endpoint(path)
|
||||
return build_cloud_aware_url(base, path, force_cloud=cloud)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API key resolution
|
||||
# =============================================================================
|
||||
|
||||
def resolve_api_key(explicit: str | None) -> str | None:
|
||||
"""Look up API key from CLI flag → env var. Strips whitespace and quotes."""
|
||||
val = explicit if explicit else os.environ.get(ENV_API_KEY)
|
||||
if val is None:
|
||||
return None
|
||||
val = val.strip().strip("'\"")
|
||||
return val or None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP transport
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class HTTPResponse:
|
||||
status: int
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
url: str # final URL after redirects
|
||||
|
||||
def text(self, encoding: str = "utf-8") -> str:
|
||||
return self.body.decode(encoding, errors="replace")
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.body.decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def _sleep_backoff(attempt: int, base: float = RETRY_BASE_DELAY, cap: float = RETRY_MAX_DELAY) -> None:
|
||||
"""Sleep with full-jitter exponential backoff."""
|
||||
delay = min(cap, base * (2 ** attempt))
|
||||
delay = random.uniform(0, delay)
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def http_request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
json_body: Any = None,
|
||||
data: bytes | None = None,
|
||||
files: dict | None = None,
|
||||
form: dict | None = None,
|
||||
timeout: float = DEFAULT_HTTP_TIMEOUT,
|
||||
follow_redirects: bool = True,
|
||||
retries: int = DEFAULT_RETRIES,
|
||||
stream: bool = False,
|
||||
sink: Path | None = None,
|
||||
) -> HTTPResponse:
|
||||
"""Single entry point for all HTTP traffic.
|
||||
|
||||
Behavior:
|
||||
- Retries on connection errors and on HTTP statuses in RETRY_STATUS_CODES,
|
||||
with exponential backoff + jitter.
|
||||
- For cross-host redirects, drops Authorization-style headers (so signed
|
||||
URLs don't leak the API key to S3/CloudFront).
|
||||
- When `stream=True` and `sink` is a Path, streams the response body to
|
||||
disk in 64 KiB chunks instead of buffering.
|
||||
|
||||
Either `json_body`, `data`, or `files`+`form` may be supplied (mutually exclusive).
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {}
|
||||
headers = dict(headers) # copy
|
||||
headers.setdefault("User-Agent", "hermes-comfyui-skill/5.0")
|
||||
|
||||
if files or form is not None:
|
||||
# Multipart upload — needs `requests`. The stdlib fallback lacks
|
||||
# multipart encoding helpers; raise a clear error.
|
||||
if not HAS_REQUESTS:
|
||||
raise RuntimeError(
|
||||
"Multipart upload requires the `requests` package. "
|
||||
"Install with: pip install requests"
|
||||
)
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
resp = _http_once(
|
||||
method=method, url=url, headers=headers,
|
||||
json_body=json_body, data=data, files=files, form=form,
|
||||
timeout=timeout, follow_redirects=follow_redirects,
|
||||
stream=stream, sink=sink,
|
||||
)
|
||||
if resp.status in RETRY_STATUS_CODES and attempt + 1 < retries:
|
||||
_sleep_backoff(attempt)
|
||||
continue
|
||||
return resp
|
||||
except (TimeoutError, ConnectionError, OSError) as e:
|
||||
last_exc = e
|
||||
if attempt + 1 < retries:
|
||||
_sleep_backoff(attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
# Should not reach here unless retries was 0
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise RuntimeError("http_request: retries exhausted with no response")
|
||||
|
||||
|
||||
_SENSITIVE_HEADERS = ("x-api-key", "authorization", "cookie")
|
||||
|
||||
|
||||
if HAS_REQUESTS:
|
||||
class _StripSensitiveOnRedirectSession(requests.Session):
|
||||
"""Session that drops sensitive headers on cross-host redirects.
|
||||
|
||||
`requests` already strips `Authorization` cross-host (rebuild_auth),
|
||||
but it does NOT strip custom headers like `X-API-Key`. We override
|
||||
`rebuild_auth` to additionally strip every header in
|
||||
`_SENSITIVE_HEADERS` when the destination is a different host —
|
||||
critical when ComfyUI Cloud's `/api/view` redirects to a signed S3 URL.
|
||||
"""
|
||||
|
||||
def rebuild_auth(self, prepared_request, response): # type: ignore[override]
|
||||
super().rebuild_auth(prepared_request, response)
|
||||
try:
|
||||
old_url = response.request.url
|
||||
new_url = prepared_request.url
|
||||
old_host = (urlparse(old_url).hostname or "").lower()
|
||||
new_host = (urlparse(new_url).hostname or "").lower()
|
||||
if old_host and new_host and old_host != new_host:
|
||||
headers = prepared_request.headers
|
||||
for key in list(headers.keys()):
|
||||
if key.lower() in _SENSITIVE_HEADERS:
|
||||
del headers[key]
|
||||
except Exception:
|
||||
# Defensive: never let header stripping break a redirect.
|
||||
pass
|
||||
|
||||
|
||||
def _http_once(
|
||||
*, method: str, url: str, headers: dict[str, str],
|
||||
json_body: Any, data: bytes | None, files: dict | None, form: dict | None,
|
||||
timeout: float, follow_redirects: bool,
|
||||
stream: bool, sink: Path | None,
|
||||
) -> HTTPResponse:
|
||||
"""One HTTP attempt. No retry."""
|
||||
if HAS_REQUESTS:
|
||||
kwargs: dict[str, Any] = {
|
||||
"method": method, "url": url, "headers": headers,
|
||||
"timeout": timeout, "allow_redirects": follow_redirects,
|
||||
}
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
elif data is not None:
|
||||
kwargs["data"] = data
|
||||
elif files is not None or form is not None:
|
||||
kwargs["files"] = files
|
||||
kwargs["data"] = form
|
||||
if stream:
|
||||
kwargs["stream"] = True
|
||||
|
||||
# Use the subclass that strips sensitive headers cross-host
|
||||
with _StripSensitiveOnRedirectSession() as s:
|
||||
try:
|
||||
r = s.request(**kwargs)
|
||||
if stream and sink is not None:
|
||||
sink.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sink.open("wb") as f:
|
||||
for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
body = b"" # already drained
|
||||
else:
|
||||
body = r.content
|
||||
return HTTPResponse(
|
||||
status=r.status_code,
|
||||
headers={k: v for k, v in r.headers.items()},
|
||||
body=body,
|
||||
url=r.url,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Convert to TimeoutError / ConnectionError so the retry loop
|
||||
# picks them up uniformly with the stdlib path.
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
raise TimeoutError(str(e)) from e
|
||||
raise ConnectionError(str(e)) from e
|
||||
|
||||
# ---------- stdlib fallback ----------
|
||||
if json_body is not None:
|
||||
body_bytes = json.dumps(json_body).encode("utf-8")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
else:
|
||||
body_bytes = data
|
||||
req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method)
|
||||
|
||||
# urllib follows redirects by default. We need to:
|
||||
# 1) intercept cross-host redirects and drop X-API-Key
|
||||
# 2) optionally NOT follow redirects when follow_redirects=False
|
||||
class _RedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, original_host: str, follow: bool):
|
||||
self.original_host = original_host
|
||||
self.follow = follow
|
||||
|
||||
def redirect_request(self, req2, fp, code, msg, hdrs, newurl):
|
||||
if not self.follow:
|
||||
return None
|
||||
new_host = (urlparse(newurl).hostname or "").lower()
|
||||
if new_host != self.original_host:
|
||||
# Build a new request with cleaned headers
|
||||
clean_headers = {
|
||||
k: v for k, v in req2.header_items()
|
||||
if k.lower() not in ("x-api-key", "authorization", "cookie")
|
||||
}
|
||||
new_req = urllib.request.Request(newurl, headers=clean_headers, method="GET")
|
||||
return new_req
|
||||
return super().redirect_request(req2, fp, code, msg, hdrs, newurl)
|
||||
|
||||
original_host = (urlparse(url).hostname or "").lower()
|
||||
opener = urllib.request.build_opener(_RedirectHandler(original_host, follow_redirects))
|
||||
|
||||
try:
|
||||
resp = opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as e:
|
||||
return HTTPResponse(
|
||||
status=e.code,
|
||||
headers=dict(e.headers) if e.headers else {},
|
||||
body=e.read() or b"",
|
||||
url=getattr(e, "url", url),
|
||||
)
|
||||
|
||||
final_url = resp.geturl()
|
||||
final_status = resp.status
|
||||
final_headers = dict(resp.headers)
|
||||
|
||||
if stream and sink is not None:
|
||||
sink.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sink.open("wb") as f:
|
||||
while True:
|
||||
chunk = resp.read(DOWNLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return HTTPResponse(status=final_status, headers=final_headers, body=b"", url=final_url)
|
||||
|
||||
return HTTPResponse(status=final_status, headers=final_headers, body=resp.read(), url=final_url)
|
||||
|
||||
|
||||
def http_get(url: str, **kwargs: Any) -> HTTPResponse:
|
||||
return http_request("GET", url, **kwargs)
|
||||
|
||||
|
||||
def http_post(url: str, **kwargs: Any) -> HTTPResponse:
|
||||
return http_request("POST", url, **kwargs)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Workflow validation & helpers
|
||||
# =============================================================================
|
||||
|
||||
def is_api_format(workflow: Any) -> bool:
|
||||
"""API format = top-level dict where each value has `class_type`."""
|
||||
if not isinstance(workflow, dict):
|
||||
return False
|
||||
if "nodes" in workflow and "links" in workflow:
|
||||
return False
|
||||
for v in workflow.values():
|
||||
if isinstance(v, dict) and "class_type" in v:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def unwrap_workflow(payload: Any) -> dict:
|
||||
"""Unwrap common wrapper variants. Returns API-format workflow or raises ValueError."""
|
||||
if isinstance(payload, dict) and is_api_format(payload):
|
||||
return payload
|
||||
# Some files wrap workflow under "prompt" key (e.g. saved /prompt payloads)
|
||||
if isinstance(payload, dict) and "prompt" in payload and is_api_format(payload["prompt"]):
|
||||
return payload["prompt"]
|
||||
# Editor format
|
||||
if isinstance(payload, dict) and "nodes" in payload and "links" in payload:
|
||||
raise ValueError(
|
||||
"Workflow is in editor format (has top-level 'nodes' and 'links' arrays). "
|
||||
"Re-export from ComfyUI using 'Workflow → Export (API)' (newer UI) "
|
||||
"or 'Save (API Format)' (older UI)."
|
||||
)
|
||||
raise ValueError(
|
||||
"Workflow is not in API format. Each top-level entry must have a 'class_type' field."
|
||||
)
|
||||
|
||||
|
||||
def is_link(value: Any) -> bool:
|
||||
"""True if `value` is a [node_id, output_index] connection (length-2 list)."""
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 2
|
||||
and isinstance(value[0], str)
|
||||
and isinstance(value[1], int)
|
||||
)
|
||||
|
||||
|
||||
def iter_nodes(workflow: dict) -> Iterator[tuple[str, dict]]:
|
||||
"""Yield (node_id, node) for each valid API-format node."""
|
||||
for node_id, node in workflow.items():
|
||||
if isinstance(node, dict) and "class_type" in node:
|
||||
yield node_id, node
|
||||
|
||||
|
||||
def iter_model_deps(workflow: dict) -> Iterator[dict]:
|
||||
"""Yield {node_id, class_type, field, value, folder} for each model dependency."""
|
||||
for node_id, node in iter_nodes(workflow):
|
||||
cls = node["class_type"]
|
||||
if cls not in MODEL_LOADERS:
|
||||
continue
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
for field_name, folder in MODEL_LOADERS[cls]:
|
||||
val = inputs.get(field_name)
|
||||
if val and isinstance(val, str) and not is_link(val):
|
||||
yield {
|
||||
"node_id": node_id,
|
||||
"class_type": cls,
|
||||
"field": field_name,
|
||||
"value": val,
|
||||
"folder": folder,
|
||||
}
|
||||
|
||||
|
||||
def iter_embedding_refs(workflow: dict) -> Iterator[tuple[str, str]]:
|
||||
"""Yield (node_id, embedding_name) for every embedding mention in prompts."""
|
||||
for node_id, node in iter_nodes(workflow):
|
||||
inputs = node.get("inputs", {}) or {}
|
||||
for field_name, val in inputs.items():
|
||||
if field_name not in PROMPT_FIELDS:
|
||||
continue
|
||||
if not isinstance(val, str):
|
||||
continue
|
||||
for m in EMBEDDING_REGEX.finditer(val):
|
||||
yield node_id, m.group(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path safety
|
||||
# =============================================================================
|
||||
|
||||
def safe_path_join(base: Path, *parts: str) -> Path:
|
||||
"""Join paths, raising if the result escapes `base`.
|
||||
|
||||
Server-supplied filenames may contain `../` etc. This guards against
|
||||
path-traversal attacks when downloading outputs.
|
||||
"""
|
||||
base_resolved = base.resolve()
|
||||
candidate = base.joinpath(*parts).resolve()
|
||||
try:
|
||||
candidate.relative_to(base_resolved)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Refusing path traversal: {candidate} is outside {base_resolved}"
|
||||
) from e
|
||||
return candidate
|
||||
|
||||
|
||||
def media_type_from_filename(filename: str) -> str:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext in (".mp4", ".webm", ".avi", ".mov", ".mkv", ".gif", ".webp"):
|
||||
return "video"
|
||||
if ext in (".wav", ".mp3", ".flac", ".ogg", ".m4a"):
|
||||
return "audio"
|
||||
if ext in (".glb", ".obj", ".ply", ".gltf"):
|
||||
return "3d"
|
||||
if ext in (".json", ".txt", ".md"):
|
||||
return "text"
|
||||
return "image"
|
||||
|
||||
|
||||
def looks_like_video_workflow(workflow: dict) -> bool:
|
||||
"""Used to bump default timeout for video workflows."""
|
||||
for _, node in iter_nodes(workflow):
|
||||
if node["class_type"] in SLOW_OUTPUT_NODES:
|
||||
return True
|
||||
if node["class_type"].lower().startswith(("animatediff", "ade_", "wanvideo", "hunyuanvideo", "ltxvideo", "cogvideo")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Seed handling
|
||||
# =============================================================================
|
||||
|
||||
# ComfyUI's max seed range. Many UIs treat `-1` as "randomize on submit".
|
||||
SEED_MAX = 2**63 - 1
|
||||
SEED_MIN = 0
|
||||
|
||||
|
||||
def coerce_seed(value: Any) -> int:
|
||||
"""Convert -1 or None to a fresh random seed; otherwise return int(value).
|
||||
|
||||
Accepts numeric -1 OR string "-1" (both treated as "randomize"). Other
|
||||
parse failures raise TypeError/ValueError for the caller to surface.
|
||||
"""
|
||||
if value is None:
|
||||
return random.randint(SEED_MIN, SEED_MAX)
|
||||
# Stringly-typed -1 from CLI / JSON should also randomize
|
||||
if isinstance(value, str) and value.strip() == "-1":
|
||||
return random.randint(SEED_MIN, SEED_MAX)
|
||||
if value == -1:
|
||||
return random.randint(SEED_MIN, SEED_MAX)
|
||||
return int(value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cloud model-list normalization
|
||||
# =============================================================================
|
||||
|
||||
def parse_model_list(payload: Any) -> set[str]:
|
||||
"""Normalize model-list responses from local ComfyUI vs Comfy Cloud.
|
||||
|
||||
Local: `["a.safetensors", "b.safetensors"]`
|
||||
Cloud: `[{"name": "a.safetensors", "pathIndex": 0}, ...]`
|
||||
"""
|
||||
if not isinstance(payload, list):
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for item in payload:
|
||||
if isinstance(item, str):
|
||||
out.add(item)
|
||||
elif isinstance(item, dict):
|
||||
name = item.get("name") or item.get("filename") or item.get("path")
|
||||
if isinstance(name, str):
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Misc utilities
|
||||
# =============================================================================
|
||||
|
||||
def new_client_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def fmt_kv(d: dict) -> str:
|
||||
"""Pretty key=value for log lines."""
|
||||
return " ".join(f"{k}={v!r}" for k, v in d.items())
|
||||
|
||||
|
||||
def emit_json(obj: Any, *, indent: int = 2) -> None:
|
||||
"""Print JSON to stdout. Centralised so behavior can be tweaked (e.g., --raw)."""
|
||||
print(json.dumps(obj, indent=indent, default=str))
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
"""stderr log with consistent prefix (so JSON stdout stays clean)."""
|
||||
print(f"[comfyui-skill] {msg}", file=sys.stderr)
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
auto_fix_deps.py — Run check_deps.py, then attempt to install whatever is missing.
|
||||
|
||||
For local servers:
|
||||
- Missing custom nodes → `comfy node install <package>`
|
||||
- Missing models → `comfy model download` (only if a URL is supplied via
|
||||
--model-source-file or detected via well-known names)
|
||||
|
||||
For cloud: prints what would be needed but cannot install (cloud preinstalls
|
||||
custom nodes and most models server-side; if something genuinely isn't there,
|
||||
ask Comfy support).
|
||||
|
||||
This is conservative: it never installs without an explicit URL for models
|
||||
(downloading the wrong model is hard to undo). Custom nodes from the registry
|
||||
are auto-installed by name.
|
||||
|
||||
Usage:
|
||||
python3 auto_fix_deps.py workflow_api.json
|
||||
python3 auto_fix_deps.py workflow_api.json --models-from-file urls.json
|
||||
python3 auto_fix_deps.py workflow_api.json --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import ( # noqa: E402
|
||||
DEFAULT_LOCAL_HOST, ENV_API_KEY, emit_json, log, resolve_api_key,
|
||||
)
|
||||
from check_deps import check_deps # noqa: E402
|
||||
from _common import unwrap_workflow # noqa: E402
|
||||
|
||||
|
||||
def comfy_cli_available() -> str | None:
|
||||
"""Return command prefix for comfy-cli, or None."""
|
||||
if shutil.which("comfy"):
|
||||
return "comfy"
|
||||
if shutil.which("uvx"):
|
||||
return "uvx --from comfy-cli comfy"
|
||||
return None
|
||||
|
||||
|
||||
def run_cmd(cmd: list[str], *, dry_run: bool = False) -> tuple[int, str]:
|
||||
if dry_run:
|
||||
return 0, "[dry-run]"
|
||||
log(f"$ {' '.join(cmd)}")
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
return proc.returncode, out
|
||||
|
||||
|
||||
def install_node(package: str, *, dry_run: bool = False, comfy_cmd: str = "comfy") -> bool:
|
||||
cmd = comfy_cmd.split() + ["--skip-prompt", "node", "install", package]
|
||||
code, _ = run_cmd(cmd, dry_run=dry_run)
|
||||
return code == 0
|
||||
|
||||
|
||||
def install_model(url: str, folder: str, filename: str | None = None,
|
||||
*, dry_run: bool = False, comfy_cmd: str = "comfy",
|
||||
hf_token: str | None = None, civitai_token: str | None = None) -> bool:
|
||||
cmd = comfy_cmd.split() + [
|
||||
"--skip-prompt", "model", "download",
|
||||
"--url", url,
|
||||
"--relative-path", f"models/{folder}",
|
||||
]
|
||||
if filename:
|
||||
cmd.extend(["--filename", filename])
|
||||
if hf_token:
|
||||
cmd.extend(["--set-hf-api-token", hf_token])
|
||||
if civitai_token:
|
||||
cmd.extend(["--set-civitai-api-token", civitai_token])
|
||||
code, _ = run_cmd(cmd, dry_run=dry_run)
|
||||
return code == 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Run check_deps and install whatever is missing")
|
||||
p.add_argument("workflow")
|
||||
p.add_argument("--host", default=DEFAULT_LOCAL_HOST)
|
||||
p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}")
|
||||
p.add_argument("--models-from-file",
|
||||
help="JSON file mapping {model_filename: download_url} for models that need install")
|
||||
p.add_argument("--hf-token", help="HuggingFace token for downloads")
|
||||
p.add_argument("--civitai-token", help="CivitAI token for downloads")
|
||||
p.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be installed without doing it")
|
||||
p.add_argument("--no-restart", action="store_true",
|
||||
help="Don't suggest restarting the server after node install")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
api_key = resolve_api_key(args.api_key)
|
||||
|
||||
wf_path = Path(args.workflow).expanduser()
|
||||
if not wf_path.exists():
|
||||
emit_json({"error": f"Workflow not found: {args.workflow}"})
|
||||
return 1
|
||||
try:
|
||||
with wf_path.open() as f:
|
||||
workflow = unwrap_workflow(json.load(f))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
emit_json({"error": str(e)})
|
||||
return 1
|
||||
|
||||
report = check_deps(workflow, host=args.host, api_key=api_key)
|
||||
|
||||
if report["is_ready"]:
|
||||
emit_json({"status": "ready", "report": report})
|
||||
return 0
|
||||
|
||||
if report["is_cloud"]:
|
||||
emit_json({
|
||||
"status": "cannot_fix_cloud",
|
||||
"reason": "Comfy Cloud preinstalls nodes; if something is genuinely missing, contact support.",
|
||||
"report": report,
|
||||
})
|
||||
return 1
|
||||
|
||||
comfy_cmd = comfy_cli_available()
|
||||
if not comfy_cmd:
|
||||
emit_json({
|
||||
"status": "cannot_fix",
|
||||
"reason": "comfy-cli not on PATH; install with `pip install comfy-cli` or `pipx install comfy-cli`",
|
||||
"report": report,
|
||||
})
|
||||
return 1
|
||||
|
||||
actions: list[dict] = []
|
||||
failures: list[dict] = []
|
||||
|
||||
# ---- Install missing custom nodes ----
|
||||
seen_packages: set[str] = set()
|
||||
for entry in report["missing_nodes"]:
|
||||
cmd = entry.get("fix_command", "")
|
||||
if cmd.startswith("comfy node install "):
|
||||
package = cmd.split(" ")[-1]
|
||||
if package in seen_packages:
|
||||
continue
|
||||
seen_packages.add(package)
|
||||
ok = install_node(package, dry_run=args.dry_run, comfy_cmd=comfy_cmd)
|
||||
(actions if ok else failures).append({
|
||||
"kind": "node", "package": package, "node_class": entry["class_type"],
|
||||
"ok": ok,
|
||||
})
|
||||
else:
|
||||
failures.append({
|
||||
"kind": "node", "node_class": entry["class_type"],
|
||||
"ok": False, "reason": "No registry mapping known. " + entry.get("fix_hint", ""),
|
||||
})
|
||||
|
||||
# ---- Install missing models (only when URL provided) ----
|
||||
sources: dict[str, str] = {}
|
||||
if args.models_from_file:
|
||||
try:
|
||||
sources = json.loads(Path(args.models_from_file).read_text())
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
log(f"Could not read --models-from-file: {e}")
|
||||
|
||||
for entry in report["missing_models"]:
|
||||
filename = entry["value"]
|
||||
url = sources.get(filename)
|
||||
if not url:
|
||||
failures.append({
|
||||
"kind": "model", "filename": filename, "folder": entry["folder"],
|
||||
"ok": False, "reason": "No URL provided in --models-from-file. "
|
||||
"Refusing to guess.",
|
||||
})
|
||||
continue
|
||||
ok = install_model(
|
||||
url, entry["folder"], filename,
|
||||
dry_run=args.dry_run, comfy_cmd=comfy_cmd,
|
||||
hf_token=args.hf_token, civitai_token=args.civitai_token,
|
||||
)
|
||||
(actions if ok else failures).append({
|
||||
"kind": "model", "filename": filename, "folder": entry["folder"],
|
||||
"url": url, "ok": ok,
|
||||
})
|
||||
|
||||
# ---- Embeddings ----
|
||||
for entry in report["missing_embeddings"]:
|
||||
emb_name = entry["embedding_name"]
|
||||
# Try common extensions in user-supplied source map
|
||||
url = (sources.get(f"{emb_name}.pt")
|
||||
or sources.get(f"{emb_name}.safetensors")
|
||||
or sources.get(emb_name))
|
||||
if not url:
|
||||
failures.append({
|
||||
"kind": "embedding", "name": emb_name,
|
||||
"ok": False, "reason": "No URL provided in --models-from-file.",
|
||||
})
|
||||
continue
|
||||
target_filename = (
|
||||
f"{emb_name}.safetensors" if url.endswith(".safetensors")
|
||||
else f"{emb_name}.pt"
|
||||
)
|
||||
ok = install_model(
|
||||
url, "embeddings", target_filename,
|
||||
dry_run=args.dry_run, comfy_cmd=comfy_cmd,
|
||||
hf_token=args.hf_token, civitai_token=args.civitai_token,
|
||||
)
|
||||
(actions if ok else failures).append({
|
||||
"kind": "embedding", "name": emb_name, "url": url, "ok": ok,
|
||||
})
|
||||
|
||||
needs_restart = any(a["kind"] == "node" and a.get("ok") for a in actions)
|
||||
|
||||
emit_json({
|
||||
"status": "fixed" if not failures else "partial",
|
||||
"actions_taken": actions,
|
||||
"failures": failures,
|
||||
"needs_server_restart": needs_restart and not args.no_restart,
|
||||
"restart_hint": "comfy stop && comfy launch --background",
|
||||
"dry_run": args.dry_run,
|
||||
})
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Regular → Executable
+385
-130
@@ -1,182 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
check_deps.py — Check if a ComfyUI workflow's dependencies (custom nodes and models) are installed.
|
||||
check_deps.py — Verify a ComfyUI workflow's dependencies (custom nodes, models,
|
||||
embeddings) against a running server.
|
||||
|
||||
Queries the running ComfyUI server for installed nodes (via /object_info) and models
|
||||
(via /models/{folder}), then diffs against what the workflow requires.
|
||||
Improvements over v1:
|
||||
- Cloud-aware endpoint mapping (handles `/api/experiment/models/{folder}` and
|
||||
`/api/object_info` variants verified against live cloud API)
|
||||
- Distinguishes 200-empty (genuinely no models in folder) vs 404
|
||||
(folder doesn't exist) vs 403 (auth/tier issue) — no silent passes
|
||||
- Outputs concrete remediation commands (e.g. `comfy node install <name>`)
|
||||
when nodes are missing
|
||||
- Detects embedding references inside prompt strings as model deps
|
||||
- Skips check on cloud free tier `/api/object_info` (403) without false alarm
|
||||
- Accepts API key from CLI flag OR $COMFY_CLOUD_API_KEY env var
|
||||
|
||||
Usage:
|
||||
python3 check_deps.py workflow_api.json
|
||||
python3 check_deps.py workflow_api.json --host 127.0.0.1 --port 8188
|
||||
python3 check_deps.py workflow_api.json --host https://cloud.comfy.org --api-key KEY
|
||||
python3 check_deps.py workflow_api.json --host https://cloud.comfy.org
|
||||
|
||||
Output format:
|
||||
{
|
||||
"is_ready": true/false,
|
||||
"missing_nodes": ["NodeClassName", ...],
|
||||
"missing_models": [{"class_type": "...", "field": "...", "value": "...", "folder": "..."}],
|
||||
"installed_nodes_count": 123,
|
||||
"required_nodes": ["KSampler", "CLIPTextEncode", ...]
|
||||
}
|
||||
|
||||
Requires: Python 3.10+, requests (or urllib as fallback)
|
||||
Stdlib-only. Python 3.10+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
try:
|
||||
import requests
|
||||
HAS_REQUESTS = True
|
||||
except ImportError:
|
||||
HAS_REQUESTS = False
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import ( # noqa: E402
|
||||
DEFAULT_LOCAL_HOST, ENV_API_KEY,
|
||||
emit_json, folder_aliases_for, http_get, is_cloud_host,
|
||||
iter_embedding_refs, iter_model_deps, iter_nodes, parse_model_list,
|
||||
resolve_api_key, resolve_url, unwrap_workflow,
|
||||
)
|
||||
|
||||
# Known model loader node types and which folder they reference
|
||||
MODEL_LOADERS = {
|
||||
"CheckpointLoaderSimple": ("ckpt_name", "checkpoints"),
|
||||
"CheckpointLoader": ("ckpt_name", "checkpoints"),
|
||||
"unCLIPCheckpointLoader": ("ckpt_name", "checkpoints"),
|
||||
"LoraLoader": ("lora_name", "loras"),
|
||||
"LoraLoaderModelOnly": ("lora_name", "loras"),
|
||||
"VAELoader": ("vae_name", "vae"),
|
||||
"ControlNetLoader": ("control_net_name", "controlnet"),
|
||||
"DiffControlNetLoader": ("control_net_name", "controlnet"),
|
||||
"CLIPLoader": ("clip_name", "clip"),
|
||||
"DualCLIPLoader": ("clip_name1", "clip"),
|
||||
"UNETLoader": ("unet_name", "unet"),
|
||||
"DiffusionModelLoader": ("model_name", "diffusion_models"),
|
||||
"UpscaleModelLoader": ("model_name", "upscale_models"),
|
||||
"CLIPVisionLoader": ("clip_name", "clip_vision"),
|
||||
"StyleModelLoader": ("style_model_name", "style_models"),
|
||||
"GLIGENLoader": ("gligen_name", "gligen"),
|
||||
"HypernetworkLoader": ("hypernetwork_name", "hypernetworks"),
|
||||
|
||||
# Known node → custom-node-package map. When a workflow needs a node we don't
|
||||
# recognize, suggesting the right `comfy node install ...` makes the difference
|
||||
# between a working agent and a stuck one.
|
||||
NODE_TO_PACKAGE: dict[str, str] = {
|
||||
# rgthree (Reroute is JS-only and doesn't appear in /object_info)
|
||||
"Power Lora Loader (rgthree)": "rgthree-comfy",
|
||||
"Image Comparer (rgthree)": "rgthree-comfy",
|
||||
"Seed (rgthree)": "rgthree-comfy",
|
||||
"Display Any (rgthree)": "rgthree-comfy",
|
||||
"Display Int (rgthree)": "rgthree-comfy",
|
||||
# Impact pack
|
||||
"FaceDetailer": "comfyui-impact-pack",
|
||||
"DetailerForEach": "comfyui-impact-pack",
|
||||
"BboxDetectorSEGS": "comfyui-impact-pack",
|
||||
"SAMLoader": "comfyui-impact-pack",
|
||||
"ImpactWildcardProcessor": "comfyui-impact-pack",
|
||||
# Impact subpack (separate package)
|
||||
"UltralyticsDetectorProvider": "comfyui-impact-subpack",
|
||||
# Was Node Suite
|
||||
"Image Save": "was-node-suite-comfyui",
|
||||
"Number Counter": "was-node-suite-comfyui",
|
||||
"Text String": "was-node-suite-comfyui",
|
||||
# easy-use
|
||||
"easy fullLoader": "comfyui-easy-use",
|
||||
"easy positive": "comfyui-easy-use",
|
||||
"easy negative": "comfyui-easy-use",
|
||||
"easy seed": "comfyui-easy-use",
|
||||
"easy imageSave": "comfyui-easy-use",
|
||||
# Video Helper Suite
|
||||
"VHS_VideoCombine": "comfyui-videohelpersuite",
|
||||
"VHS_LoadVideo": "comfyui-videohelpersuite",
|
||||
"VHS_LoadAudio": "comfyui-videohelpersuite",
|
||||
# AnimateDiff
|
||||
"ADE_AnimateDiffLoaderWithContext": "comfyui-animatediff-evolved",
|
||||
"ADE_AnimateDiffLoaderGen1": "comfyui-animatediff-evolved",
|
||||
"ADE_LoadAnimateDiffModel": "comfyui-animatediff-evolved",
|
||||
# ControlNet aux preprocessors (full class names)
|
||||
"CannyEdgePreprocessor": "comfyui_controlnet_aux",
|
||||
"DWPreprocessor": "comfyui_controlnet_aux",
|
||||
"OpenposePreprocessor": "comfyui_controlnet_aux",
|
||||
"DepthAnythingPreprocessor": "comfyui_controlnet_aux",
|
||||
"Zoe_DepthAnythingPreprocessor": "comfyui_controlnet_aux",
|
||||
"AnimalPosePreprocessor": "comfyui_controlnet_aux",
|
||||
# IPAdapter Plus
|
||||
"IPAdapterAdvanced": "comfyui_ipadapter_plus",
|
||||
"IPAdapterUnifiedLoader": "comfyui_ipadapter_plus",
|
||||
"IPAdapterModelLoader": "comfyui_ipadapter_plus",
|
||||
"IPAdapterInsightFaceLoader": "comfyui_ipadapter_plus",
|
||||
# InstantID
|
||||
"InstantIDModelLoader": "comfyui_instantid",
|
||||
"ApplyInstantID": "comfyui_instantid",
|
||||
# Comfy essentials (note: registry slug uses underscore, not hyphen)
|
||||
"GetImageSize+": "comfyui_essentials",
|
||||
"ImageBatchMultiple+": "comfyui_essentials",
|
||||
# pysssss
|
||||
"ShowText|pysssss": "comfyui-custom-scripts",
|
||||
"PreviewImage|pysssss": "comfyui-custom-scripts",
|
||||
# SUPIR
|
||||
"SUPIR_Upscale": "comfyui-supir",
|
||||
"SUPIR_first_stage": "comfyui-supir",
|
||||
# GGUF (case-sensitive registry slug)
|
||||
"UNETLoaderGGUF": "ComfyUI-GGUF",
|
||||
"DualCLIPLoaderGGUF": "ComfyUI-GGUF",
|
||||
# Florence2
|
||||
"Florence2Run": "comfyui-florence2",
|
||||
# WAS
|
||||
"Image Filter Adjustments": "was-node-suite-comfyui",
|
||||
# Photomaker (case-sensitive)
|
||||
"PhotoMakerLoader": "ComfyUI-PhotoMaker-Plus",
|
||||
# Wan video (case-sensitive)
|
||||
"WanVideoSampler": "ComfyUI-WanVideoWrapper",
|
||||
"WanVideoModelLoader": "ComfyUI-WanVideoWrapper",
|
||||
}
|
||||
|
||||
# Nodes whose package isn't on the comfy registry — need git-URL install via
|
||||
# ComfyUI-Manager. We surface a helpful hint instead of an unrunnable command.
|
||||
NODE_TO_GIT_URL: dict[str, str] = {
|
||||
"HunyuanVideoSampler": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",
|
||||
"HunyuanVideoModelLoader": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",
|
||||
}
|
||||
|
||||
|
||||
def http_get(url: str, headers: dict = None) -> tuple:
|
||||
"""GET request, returns (status_code, body_text)."""
|
||||
if HAS_REQUESTS:
|
||||
r = requests.get(url, headers=headers or {}, timeout=30)
|
||||
return r.status_code, r.text
|
||||
else:
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
def fetch_object_info(url: str, headers: dict) -> tuple[set[str] | None, dict | None]:
|
||||
"""Returns (installed_node_set, error_info). Error info is a dict if we
|
||||
couldn't query (e.g. cloud free tier), else None.
|
||||
"""
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
if r.status == 200:
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
return resp.status, resp.read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()
|
||||
data = r.json()
|
||||
if isinstance(data, dict):
|
||||
return set(data.keys()), None
|
||||
except Exception:
|
||||
pass
|
||||
return None, {"http_status": 200, "reason": "non-dict response"}
|
||||
if r.status == 403:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {"raw": r.text()[:200]}
|
||||
return None, {"http_status": 403, "reason": "forbidden", "body": body}
|
||||
if r.status == 404:
|
||||
return None, {"http_status": 404, "reason": "endpoint not found"}
|
||||
return None, {"http_status": r.status, "reason": "unexpected", "body": r.text()[:200]}
|
||||
|
||||
|
||||
def check_deps(workflow_path: str, host: str = "http://127.0.0.1:8188", api_key: str = None):
|
||||
"""Check workflow dependencies against a running server."""
|
||||
# Load workflow
|
||||
with open(workflow_path) as f:
|
||||
workflow = json.load(f)
|
||||
def _fetch_one_folder(
|
||||
base: str, folder: str, headers: dict, *, is_cloud: bool,
|
||||
) -> tuple[set[str] | None, dict | None]:
|
||||
"""Single-folder fetch, no aliasing. Returns (installed_set, error_info)."""
|
||||
url = resolve_url(base, f"/models/{folder}", is_cloud=is_cloud)
|
||||
r = http_get(url, headers=headers, retries=2, timeout=30)
|
||||
if r.status == 200:
|
||||
try:
|
||||
return parse_model_list(r.json()), None
|
||||
except Exception:
|
||||
return set(), {"http_status": 200, "reason": "non-list response"}
|
||||
if r.status == 404:
|
||||
body_text = r.text()
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {"raw": body_text[:200]}
|
||||
code = body.get("code") if isinstance(body, dict) else None
|
||||
if code == "folder_not_found":
|
||||
# Folder is genuinely empty/missing on server — not the same as
|
||||
# "endpoint missing". Return empty set with informational error.
|
||||
return set(), {"http_status": 404, "reason": "folder_empty_or_unknown", "body": body}
|
||||
return None, {"http_status": 404, "reason": "endpoint not found", "body": body}
|
||||
if r.status == 403:
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
return None, {"http_status": 403, "reason": "forbidden", "body": body}
|
||||
return None, {"http_status": r.status, "reason": "unexpected"}
|
||||
|
||||
# Validate format
|
||||
if "nodes" in workflow and "links" in workflow:
|
||||
return {"error": "Workflow is in editor format, not API format."}
|
||||
|
||||
headers = {}
|
||||
def fetch_models_for_folder(
|
||||
base: str, folder: str, headers: dict, *, is_cloud: bool,
|
||||
) -> tuple[set[str] | None, dict | None]:
|
||||
"""Fetch installed models for a folder, trying aliases.
|
||||
|
||||
Folder renames over time (e.g. unet → diffusion_models, clip → text_encoders)
|
||||
mean a workflow asking for a model in `unet` may need to look in
|
||||
`diffusion_models`. We union models from every reachable alias.
|
||||
|
||||
Returns (combined_set | None, last_error | None).
|
||||
"""
|
||||
aliases = folder_aliases_for(folder)
|
||||
combined: set[str] = set()
|
||||
any_success = False
|
||||
last_err: dict | None = None
|
||||
for alias in aliases:
|
||||
models, err = _fetch_one_folder(base, alias, headers, is_cloud=is_cloud)
|
||||
if models is not None:
|
||||
combined.update(models)
|
||||
any_success = True
|
||||
last_err = None
|
||||
else:
|
||||
last_err = err
|
||||
if not any_success:
|
||||
return None, last_err
|
||||
return combined, None
|
||||
|
||||
|
||||
def fetch_embeddings(base: str, headers: dict, *, is_cloud: bool) -> tuple[set[str] | None, dict | None]:
|
||||
"""Local ComfyUI exposes /embeddings; cloud uses /experiment/models/embeddings."""
|
||||
if is_cloud:
|
||||
return fetch_models_for_folder(base, "embeddings", headers, is_cloud=True)
|
||||
# Local: dedicated /embeddings returns a flat list of names
|
||||
r = http_get(resolve_url(base, "/embeddings", is_cloud=False), headers=headers, retries=2)
|
||||
if r.status == 200:
|
||||
try:
|
||||
data = r.json()
|
||||
if isinstance(data, list):
|
||||
# Strip extensions from the registered names since prompt syntax
|
||||
# usually omits them ("embedding:goodvibes" vs "goodvibes.pt")
|
||||
names = set()
|
||||
for n in data:
|
||||
if isinstance(n, str):
|
||||
names.add(n)
|
||||
# Also store stem for fuzzy matching
|
||||
names.add(Path(n).stem)
|
||||
return names, None
|
||||
except Exception:
|
||||
pass
|
||||
return None, {"http_status": r.status, "reason": "unexpected"}
|
||||
|
||||
|
||||
def normalize_for_match(name: str) -> set[str]:
|
||||
"""Generate matching variants of a model name (with/without extension, slashes, etc.)"""
|
||||
s = {name}
|
||||
s.add(Path(name).stem)
|
||||
s.add(Path(name).name)
|
||||
# ComfyUI sometimes strips/keeps the leading folder
|
||||
if "/" in name or "\\" in name:
|
||||
flat = name.replace("\\", "/").split("/")[-1]
|
||||
s.add(flat)
|
||||
s.add(Path(flat).stem)
|
||||
return {x for x in s if x}
|
||||
|
||||
|
||||
def model_present(needed: str, installed: set[str]) -> bool:
|
||||
if not installed:
|
||||
return False
|
||||
needed_variants = normalize_for_match(needed)
|
||||
installed_norm: set[str] = set()
|
||||
for inst in installed:
|
||||
installed_norm.update(normalize_for_match(inst))
|
||||
return bool(needed_variants & installed_norm)
|
||||
|
||||
|
||||
def suggest_install_command(node_class: str) -> str | None:
|
||||
pkg = NODE_TO_PACKAGE.get(node_class)
|
||||
if pkg:
|
||||
return f"comfy node install {pkg}"
|
||||
return None
|
||||
|
||||
|
||||
def suggest_git_url(node_class: str) -> str | None:
|
||||
"""For nodes not on the registry, return a git URL the user can hand to
|
||||
ComfyUI-Manager's `/manager/queue/install` endpoint."""
|
||||
return NODE_TO_GIT_URL.get(node_class)
|
||||
|
||||
|
||||
def check_deps(
|
||||
workflow: dict, host: str, *, api_key: str | None = None,
|
||||
) -> dict:
|
||||
headers: dict[str, str] = {}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
parsed_host = urlparse(host)
|
||||
hostname = (parsed_host.hostname or "").lower()
|
||||
is_cloud_host = hostname == "cloud.comfy.org" or hostname.endswith(".cloud.comfy.org")
|
||||
is_cloud = is_cloud_host or api_key is not None
|
||||
is_cloud = is_cloud_host(host)
|
||||
base = host.rstrip("/")
|
||||
|
||||
# Get installed node types
|
||||
object_info_url = f"{base}/api/object_info" if is_cloud else f"{base}/object_info"
|
||||
status, body = http_get(object_info_url, headers)
|
||||
if status != 200:
|
||||
return {"error": f"Cannot reach server at {host}. Is ComfyUI running? HTTP {status}"}
|
||||
# ---- 1. Required nodes ----
|
||||
required_nodes: set[str] = set()
|
||||
for _, node in iter_nodes(workflow):
|
||||
required_nodes.add(node["class_type"])
|
||||
|
||||
installed_nodes = set(json.loads(body).keys())
|
||||
object_info_url = resolve_url(base, "/object_info", is_cloud=is_cloud)
|
||||
installed_nodes, obj_err = fetch_object_info(object_info_url, headers)
|
||||
|
||||
# Find required node types from workflow
|
||||
required_nodes = set()
|
||||
for node_id, node in workflow.items():
|
||||
if isinstance(node, dict) and "class_type" in node:
|
||||
required_nodes.add(node["class_type"])
|
||||
missing_nodes: list[dict] = []
|
||||
node_check_skipped = False
|
||||
if installed_nodes is None:
|
||||
# Couldn't query (e.g. cloud free tier). Don't false-alarm; mark skipped.
|
||||
node_check_skipped = True
|
||||
else:
|
||||
for cls in sorted(required_nodes):
|
||||
if cls not in installed_nodes:
|
||||
entry = {"class_type": cls}
|
||||
cmd = suggest_install_command(cls)
|
||||
git_url = suggest_git_url(cls)
|
||||
if cmd:
|
||||
entry["fix_command"] = cmd
|
||||
elif git_url:
|
||||
entry["fix_git_url"] = git_url
|
||||
entry["fix_hint"] = (
|
||||
f"Not on registry. Install via Manager with this git URL: {git_url}"
|
||||
)
|
||||
else:
|
||||
entry["fix_hint"] = (
|
||||
"Search https://registry.comfy.org or "
|
||||
"use ComfyUI-Manager UI to find the package providing this node."
|
||||
)
|
||||
missing_nodes.append(entry)
|
||||
|
||||
missing_nodes = sorted(required_nodes - installed_nodes)
|
||||
# ---- 2. Required models ----
|
||||
model_cache: dict[str, tuple[set[str] | None, dict | None]] = {}
|
||||
missing_models: list[dict] = []
|
||||
folder_errors: dict[str, dict] = {}
|
||||
|
||||
# Check model dependencies
|
||||
missing_models = []
|
||||
model_cache = {} # folder → set of installed model filenames
|
||||
|
||||
for node_id, node in workflow.items():
|
||||
if not isinstance(node, dict) or "class_type" not in node:
|
||||
continue
|
||||
class_type = node["class_type"]
|
||||
if class_type not in MODEL_LOADERS:
|
||||
continue
|
||||
|
||||
field, folder = MODEL_LOADERS[class_type]
|
||||
inputs = node.get("inputs", {})
|
||||
model_name = inputs.get(field)
|
||||
|
||||
if not model_name or not isinstance(model_name, str):
|
||||
continue
|
||||
|
||||
# Fetch installed models for this folder (cached)
|
||||
for dep in iter_model_deps(workflow):
|
||||
folder = dep["folder"]
|
||||
if folder not in model_cache:
|
||||
models_url = f"{base}/api/models/{folder}" if is_cloud else f"{base}/models/{folder}"
|
||||
s, b = http_get(models_url, headers)
|
||||
if s == 200:
|
||||
model_cache[folder] = set(json.loads(b))
|
||||
else:
|
||||
model_cache[folder] = set()
|
||||
model_cache[folder] = fetch_models_for_folder(
|
||||
base, folder, headers, is_cloud=is_cloud,
|
||||
)
|
||||
installed, err = model_cache[folder]
|
||||
if installed is None:
|
||||
# Couldn't enumerate this folder — record once
|
||||
folder_errors.setdefault(folder, err or {})
|
||||
# Don't flag as missing (we don't know); the folder_errors block surfaces this
|
||||
continue
|
||||
if not model_present(dep["value"], installed):
|
||||
entry = dict(dep)
|
||||
entry["fix_hint"] = (
|
||||
f"comfy model download --url <URL> --relative-path models/{folder} "
|
||||
f"--filename {dep['value']!r}"
|
||||
)
|
||||
missing_models.append(entry)
|
||||
|
||||
if model_name not in model_cache[folder]:
|
||||
missing_models.append({
|
||||
"node_id": node_id,
|
||||
"class_type": class_type,
|
||||
"field": field,
|
||||
"value": model_name,
|
||||
"folder": folder,
|
||||
# ---- 3. Embedding refs in prompts ----
|
||||
emb_installed, emb_err = fetch_embeddings(base, headers, is_cloud=is_cloud)
|
||||
missing_embeddings: list[dict] = []
|
||||
seen_emb: set[tuple[str, str]] = set()
|
||||
for nid, emb_name in iter_embedding_refs(workflow):
|
||||
if (nid, emb_name) in seen_emb:
|
||||
continue
|
||||
seen_emb.add((nid, emb_name))
|
||||
if emb_installed is None:
|
||||
# Couldn't enumerate — skip silently here, surface the error in the
|
||||
# folder_errors block
|
||||
continue
|
||||
if not model_present(emb_name, emb_installed):
|
||||
missing_embeddings.append({
|
||||
"node_id": nid,
|
||||
"embedding_name": emb_name,
|
||||
"folder": "embeddings",
|
||||
"fix_hint": (
|
||||
f"Download {emb_name}.pt or .safetensors and place in "
|
||||
f"models/embeddings/, or `comfy model download --url <URL> "
|
||||
f"--relative-path models/embeddings`"
|
||||
),
|
||||
})
|
||||
|
||||
is_ready = len(missing_nodes) == 0 and len(missing_models) == 0
|
||||
if emb_err and emb_installed is None:
|
||||
folder_errors.setdefault("embeddings", emb_err)
|
||||
|
||||
is_ready = (
|
||||
not node_check_skipped
|
||||
and not missing_nodes
|
||||
and not missing_models
|
||||
and not missing_embeddings
|
||||
)
|
||||
|
||||
return {
|
||||
"is_ready": is_ready,
|
||||
"node_check_skipped": node_check_skipped,
|
||||
"node_check_skip_reason": obj_err if node_check_skipped else None,
|
||||
"missing_nodes": missing_nodes,
|
||||
"missing_models": missing_models,
|
||||
"installed_nodes_count": len(installed_nodes),
|
||||
"missing_embeddings": missing_embeddings,
|
||||
"folder_errors": folder_errors,
|
||||
# 0 is a legitimate count (e.g. empty server). Use None only when not queried.
|
||||
"installed_node_count": len(installed_nodes) if installed_nodes is not None else None,
|
||||
"required_node_count": len(required_nodes),
|
||||
"required_nodes": sorted(required_nodes),
|
||||
"host": base,
|
||||
"is_cloud": is_cloud,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies")
|
||||
parser.add_argument("workflow", help="Path to workflow API JSON file")
|
||||
parser.add_argument("--host", default="http://127.0.0.1:8188", help="ComfyUI server URL")
|
||||
parser.add_argument("--port", type=int, help="Server port (overrides --host port)")
|
||||
parser.add_argument("--api-key", help="API key for cloud")
|
||||
args = parser.parse_args()
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies against a running server")
|
||||
p.add_argument("workflow", help="Path to workflow API JSON file")
|
||||
p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL")
|
||||
p.add_argument("--port", type=int, help="Server port (overrides --host port)")
|
||||
p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)")
|
||||
p.add_argument("--strict", action="store_true",
|
||||
help="Exit non-zero if node check is skipped (e.g. on cloud free tier)")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
# Handle --port override
|
||||
host = args.host
|
||||
if args.port and ":" not in host.split("//")[-1]:
|
||||
host = f"{host}:{args.port}"
|
||||
if args.port is not None:
|
||||
# Strip any port from host and append --port
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
parsed = urlparse(host if "://" in host else f"http://{host}")
|
||||
new_netloc = f"{parsed.hostname}:{args.port}"
|
||||
host = urlunparse(parsed._replace(netloc=new_netloc))
|
||||
|
||||
result = check_deps(args.workflow, host=host, api_key=args.api_key)
|
||||
print(json.dumps(result, indent=2))
|
||||
api_key = resolve_api_key(args.api_key)
|
||||
|
||||
if result.get("error"):
|
||||
sys.exit(1)
|
||||
if not result.get("is_ready", False):
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
wf_path = Path(args.workflow).expanduser()
|
||||
if not wf_path.exists():
|
||||
emit_json({"error": f"Workflow file not found: {args.workflow}"})
|
||||
return 1
|
||||
try:
|
||||
with wf_path.open() as f:
|
||||
payload = json.load(f)
|
||||
workflow = unwrap_workflow(payload)
|
||||
except ValueError as e:
|
||||
emit_json({"error": str(e)})
|
||||
return 1
|
||||
except json.JSONDecodeError as e:
|
||||
emit_json({"error": f"Invalid JSON: {e}"})
|
||||
return 1
|
||||
|
||||
try:
|
||||
result = check_deps(workflow, host=host, api_key=api_key)
|
||||
except Exception as e:
|
||||
emit_json({"error": f"Dep check failed: {e}", "host": host})
|
||||
return 1
|
||||
|
||||
emit_json(result)
|
||||
|
||||
if not result["is_ready"]:
|
||||
return 1
|
||||
if args.strict and result["node_check_skipped"]:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,113 +1,263 @@
|
||||
#!/usr/bin/env bash
|
||||
# ComfyUI Setup — Install, launch, and verify using the official comfy-cli.
|
||||
# Usage: bash scripts/comfyui_setup.sh [--nvidia|--amd|--m-series|--cpu]
|
||||
#
|
||||
# If no flag is passed, runs hardware_check.py to detect the right one
|
||||
# automatically, and refuses to install locally when the verdict is "cloud"
|
||||
# (no usable GPU, too little VRAM, Intel Mac, etc.) — pointing the user
|
||||
# at Comfy Cloud instead.
|
||||
# Improvements over v1:
|
||||
# - Prefers `pipx` / `uvx` over global `pip install` (avoids polluting system Python)
|
||||
# - Idempotent: detects already-running server and skips re-launch
|
||||
# - Configurable port via --port=N (default 8188)
|
||||
# - Configurable workspace via --workspace=PATH
|
||||
# - Persistent log file in /tmp/comfyui_setup.<pid>.log for debugging
|
||||
# - SIGINT trap cleans up partial state
|
||||
# - Refuses local install when hardware_check.py verdict is "cloud"
|
||||
# - Forwards extra flags to comfy-cli (e.g. --cuda-version=12.4)
|
||||
#
|
||||
# Prerequisites: Python 3.10+, pip
|
||||
# What it does:
|
||||
# 0. Hardware check (skipped if a flag was passed explicitly)
|
||||
# 1. Installs comfy-cli (if not present)
|
||||
# 2. Disables analytics tracking
|
||||
# 3. Installs ComfyUI + ComfyUI-Manager
|
||||
# 4. Launches server in background
|
||||
# 5. Verifies server is reachable
|
||||
# Usage:
|
||||
# bash scripts/comfyui_setup.sh
|
||||
# (auto-detects GPU; uses recommendation from hardware_check.py)
|
||||
# bash scripts/comfyui_setup.sh --nvidia
|
||||
# bash scripts/comfyui_setup.sh --m-series --port=8190
|
||||
# bash scripts/comfyui_setup.sh --amd --workspace=/data/comfy
|
||||
#
|
||||
# Flags:
|
||||
# --nvidia | --amd | --m-series | --cpu GPU selection (skips hw check)
|
||||
# --port=N HTTP port (default 8188)
|
||||
# --workspace=PATH ComfyUI install location
|
||||
# --skip-launch Install only, don't start server
|
||||
# --force-cloud-override Install locally even if hw says cloud
|
||||
# -- Pass remaining args to `comfy install`
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HARDWARE_CHECK="$SCRIPT_DIR/hardware_check.py"
|
||||
LOG_FILE="/tmp/comfyui_setup.$$.log"
|
||||
PORT=8188
|
||||
WORKSPACE=""
|
||||
GPU_FLAG=""
|
||||
SKIP_LAUNCH=0
|
||||
FORCE_CLOUD_OVERRIDE=0
|
||||
EXTRA_INSTALL_ARGS=()
|
||||
|
||||
# Step 0: Hardware check (auto-detect GPU flag when none was provided)
|
||||
if [ $# -ge 1 ]; then
|
||||
GPU_FLAG="$1"
|
||||
echo "==> GPU flag: $GPU_FLAG (user-supplied, skipping hardware check)"
|
||||
else
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo "==> Setup exited with status $exit_code. Log: $LOG_FILE" >&2
|
||||
fi
|
||||
exit $exit_code
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
log() { echo "==> $*" | tee -a "$LOG_FILE" >&2; }
|
||||
err() { echo "ERROR: $*" | tee -a "$LOG_FILE" >&2; }
|
||||
|
||||
# --- Argument parsing ---
|
||||
PASSTHROUGH=0
|
||||
for arg in "$@"; do
|
||||
if [ "$PASSTHROUGH" -eq 1 ]; then
|
||||
EXTRA_INSTALL_ARGS+=("$arg")
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--nvidia|--amd|--m-series|--cpu)
|
||||
GPU_FLAG="$arg"
|
||||
;;
|
||||
--port=*)
|
||||
PORT="${arg#*=}"
|
||||
;;
|
||||
--workspace=*)
|
||||
WORKSPACE="${arg#*=}"
|
||||
;;
|
||||
--skip-launch)
|
||||
SKIP_LAUNCH=1
|
||||
;;
|
||||
--force-cloud-override)
|
||||
FORCE_CLOUD_OVERRIDE=1
|
||||
;;
|
||||
--)
|
||||
PASSTHROUGH=1
|
||||
;;
|
||||
--help|-h)
|
||||
# Print the leading comment block, stripping the `# ` prefix.
|
||||
# Stops at the first blank line which separates docs from code.
|
||||
awk '
|
||||
NR == 1 { next } # skip shebang
|
||||
/^[^#]/ { exit } # stop at first non-comment line
|
||||
/^$/ { exit } # ...or first blank line
|
||||
{ sub(/^# ?/, ""); print }
|
||||
' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $arg"
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
log "Logging to $LOG_FILE"
|
||||
|
||||
# --- Step 0: Hardware check (skipped if user gave an explicit GPU flag) ---
|
||||
if [ -z "$GPU_FLAG" ]; then
|
||||
if [ ! -f "$HARDWARE_CHECK" ]; then
|
||||
echo "==> hardware_check.py not found, defaulting to --nvidia"
|
||||
log "hardware_check.py not found — defaulting to --nvidia"
|
||||
GPU_FLAG="--nvidia"
|
||||
else
|
||||
echo "==> Running hardware check..."
|
||||
log "Running hardware check…"
|
||||
set +e
|
||||
HW_JSON="$(python3 "$HARDWARE_CHECK" --json)"
|
||||
HW_JSON="$(python3 "$HARDWARE_CHECK" --json 2>>"$LOG_FILE")"
|
||||
HW_EXIT=$?
|
||||
set -e
|
||||
echo "$HW_JSON"
|
||||
echo ""
|
||||
|
||||
if [ -z "$HW_JSON" ]; then
|
||||
err "hardware_check.py produced no output (exit $HW_EXIT). Pass an explicit flag."
|
||||
exit 1
|
||||
fi
|
||||
echo "$HW_JSON" | tee -a "$LOG_FILE" >&2
|
||||
|
||||
VERDICT="$(echo "$HW_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("verdict",""))')"
|
||||
FLAG="$(echo "$HW_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("comfy_cli_flag") or "")')"
|
||||
|
||||
if [ "$VERDICT" = "cloud" ]; then
|
||||
echo ""
|
||||
echo "==> Hardware check: this machine is not suitable for local ComfyUI."
|
||||
echo " Recommended: Comfy Cloud — https://platform.comfy.org"
|
||||
echo ""
|
||||
echo " If you want to override and install anyway, re-run with an"
|
||||
echo " explicit flag: bash $0 --nvidia|--amd|--m-series|--cpu"
|
||||
if [ "$VERDICT" = "cloud" ] && [ "$FORCE_CLOUD_OVERRIDE" -ne 1 ]; then
|
||||
log ""
|
||||
log "Hardware check: this machine is not suitable for local ComfyUI."
|
||||
log "Recommended: Comfy Cloud — https://platform.comfy.org"
|
||||
log ""
|
||||
log "To override and force a local install, re-run with --force-cloud-override"
|
||||
log "or pass an explicit GPU flag (--nvidia|--amd|--m-series|--cpu)."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$VERDICT" = "marginal" ]; then
|
||||
log "Hardware check: verdict is MARGINAL."
|
||||
log " SD1.5 should work; SDXL/Flux may be slow or OOM."
|
||||
log " Consider Comfy Cloud for heavier workflows: https://platform.comfy.org"
|
||||
fi
|
||||
|
||||
if [ -z "$FLAG" ]; then
|
||||
echo "==> Hardware check couldn't pick a comfy-cli flag. Defaulting to --nvidia."
|
||||
echo " (For Intel Arc or unsupported hardware, use the manual install path.)"
|
||||
log "hardware_check could not pick a comfy-cli flag. Defaulting to --nvidia."
|
||||
log "(For Intel Arc or unsupported hardware, use the manual install path.)"
|
||||
GPU_FLAG="--nvidia"
|
||||
else
|
||||
GPU_FLAG="$FLAG"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$VERDICT" = "marginal" ]; then
|
||||
echo "==> Hardware check: verdict is MARGINAL."
|
||||
echo " SD1.5 should work; SDXL/Flux may be slow or OOM."
|
||||
echo " Consider Comfy Cloud for heavier workflows: https://platform.comfy.org"
|
||||
echo ""
|
||||
log "GPU flag: $GPU_FLAG"
|
||||
log "Port: $PORT"
|
||||
[ -n "$WORKSPACE" ] && log "Workspace: $WORKSPACE"
|
||||
[ "${#EXTRA_INSTALL_ARGS[@]}" -gt 0 ] && log "Extra install args: ${EXTRA_INSTALL_ARGS[*]}"
|
||||
|
||||
# --- Step 1: Install comfy-cli (prefer pipx / uvx over global pip) ---
|
||||
COMFY_BIN=""
|
||||
if command -v comfy >/dev/null 2>&1; then
|
||||
COMFY_BIN="comfy"
|
||||
log "comfy-cli already on PATH: $(comfy -v 2>/dev/null || echo 'unknown version')"
|
||||
elif command -v uvx >/dev/null 2>&1; then
|
||||
log "Using uvx (no install needed)"
|
||||
COMFY_BIN="uvx --from comfy-cli comfy"
|
||||
elif command -v pipx >/dev/null 2>&1; then
|
||||
log "Installing comfy-cli via pipx…"
|
||||
pipx install comfy-cli >>"$LOG_FILE" 2>&1
|
||||
COMFY_BIN="comfy"
|
||||
# pipx adds shims to ~/.local/bin which may need to be on PATH
|
||||
if ! command -v comfy >/dev/null 2>&1; then
|
||||
if [ -x "$HOME/.local/bin/comfy" ]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
COMFY_BIN="$HOME/.local/bin/comfy"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "Neither pipx nor uvx found. Falling back to pip install --user…"
|
||||
log " (Recommend installing pipx: https://pipx.pypa.io)"
|
||||
if ! pip install --user comfy-cli >>"$LOG_FILE" 2>&1; then
|
||||
# macOS: PEP 668 externally-managed-environment may block --user
|
||||
log "pip install --user failed. Retrying with --break-system-packages…"
|
||||
pip install --user --break-system-packages comfy-cli >>"$LOG_FILE" 2>&1 || {
|
||||
err "Could not install comfy-cli. Install pipx or uv first."
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
# Resolve the actual `comfy` script — pip --user puts it in:
|
||||
# Linux: ~/.local/bin/comfy
|
||||
# macOS: ~/Library/Python/<ver>/bin/comfy OR ~/.local/bin/comfy
|
||||
COMFY_BIN=""
|
||||
for candidate in "$HOME/.local/bin/comfy" \
|
||||
"$HOME/Library/Python/3.13/bin/comfy" \
|
||||
"$HOME/Library/Python/3.12/bin/comfy" \
|
||||
"$HOME/Library/Python/3.11/bin/comfy" \
|
||||
"$HOME/Library/Python/3.10/bin/comfy"; do
|
||||
if [ -x "$candidate" ]; then
|
||||
COMFY_BIN="$candidate"
|
||||
export PATH="$(dirname "$candidate"):$PATH"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$COMFY_BIN" ]; then
|
||||
if command -v comfy >/dev/null 2>&1; then
|
||||
COMFY_BIN="comfy"
|
||||
else
|
||||
err "Installed comfy-cli but couldn't find the 'comfy' script."
|
||||
err "Add the right Python user-bin directory to PATH and retry."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> ComfyUI Setup"
|
||||
echo " GPU flag: $GPU_FLAG"
|
||||
echo ""
|
||||
# --- Step 2: Disable analytics tracking (avoid interactive prompt) ---
|
||||
log "Disabling analytics tracking…"
|
||||
$COMFY_BIN --skip-prompt tracking disable >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
# Step 1: Install comfy-cli
|
||||
if command -v comfy >/dev/null 2>&1; then
|
||||
echo "==> comfy-cli already installed: $(comfy -v 2>/dev/null || echo 'unknown version')"
|
||||
else
|
||||
echo "==> Installing comfy-cli..."
|
||||
pip install comfy-cli
|
||||
# --- Step 3: Install ComfyUI ---
|
||||
WORKSPACE_ARG=()
|
||||
if [ -n "$WORKSPACE" ]; then
|
||||
WORKSPACE_ARG=(--workspace "$WORKSPACE")
|
||||
fi
|
||||
|
||||
# Step 2: Disable tracking (avoid interactive prompt)
|
||||
echo "==> Disabling analytics tracking..."
|
||||
comfy --skip-prompt tracking disable 2>/dev/null || true
|
||||
|
||||
# Step 3: Install ComfyUI
|
||||
if comfy which 2>/dev/null | grep -q "ComfyUI"; then
|
||||
echo "==> ComfyUI already installed at: $(comfy which 2>/dev/null)"
|
||||
if $COMFY_BIN "${WORKSPACE_ARG[@]}" which 2>/dev/null | grep -q "ComfyUI"; then
|
||||
EXISTING_WS="$($COMFY_BIN "${WORKSPACE_ARG[@]}" which 2>/dev/null || true)"
|
||||
log "ComfyUI already installed at: $EXISTING_WS"
|
||||
else
|
||||
echo "==> Installing ComfyUI ($GPU_FLAG)..."
|
||||
comfy --skip-prompt install $GPU_FLAG
|
||||
log "Installing ComfyUI ($GPU_FLAG)…"
|
||||
if ! $COMFY_BIN "${WORKSPACE_ARG[@]}" --skip-prompt install "$GPU_FLAG" "${EXTRA_INSTALL_ARGS[@]}" >>"$LOG_FILE" 2>&1; then
|
||||
err "Install failed. Tail of log:"
|
||||
tail -20 "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: Launch in background
|
||||
echo "==> Launching ComfyUI in background..."
|
||||
comfy launch --background 2>/dev/null || {
|
||||
echo "==> Background launch failed. Trying foreground check..."
|
||||
echo " You may need to run: comfy launch"
|
||||
if [ "$SKIP_LAUNCH" -eq 1 ]; then
|
||||
log "Setup complete (--skip-launch). Run \`$COMFY_BIN launch --background -- --port $PORT\` when ready."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Step 4: Detect already-running server ---
|
||||
if curl -fsS "http://127.0.0.1:$PORT/system_stats" >/dev/null 2>&1; then
|
||||
log "Server already running on port $PORT — skipping launch."
|
||||
log "Stop with \`$COMFY_BIN stop\` if you want a fresh start."
|
||||
curl -fsS "http://127.0.0.1:$PORT/system_stats" | python3 -m json.tool 2>/dev/null || true
|
||||
log "Done."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Step 5: Launch ---
|
||||
log "Launching ComfyUI in background on port $PORT…"
|
||||
LAUNCH_EXTRAS=("--" "--port" "$PORT")
|
||||
if ! $COMFY_BIN "${WORKSPACE_ARG[@]}" launch --background "${LAUNCH_EXTRAS[@]}" >>"$LOG_FILE" 2>&1; then
|
||||
err "Background launch failed. Tail of log:"
|
||||
tail -20 "$LOG_FILE" >&2
|
||||
err "Try foreground launch to see real-time errors: $COMFY_BIN launch -- --port $PORT"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Step 5: Wait for server to be ready
|
||||
echo "==> Waiting for server..."
|
||||
MAX_WAIT=30
|
||||
# --- Step 6: Wait for server ---
|
||||
log "Waiting for server…"
|
||||
MAX_WAIT=60
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
if curl -s http://127.0.0.1:8188/system_stats >/dev/null 2>&1; then
|
||||
echo "==> Server is running!"
|
||||
curl -s http://127.0.0.1:8188/system_stats | python3 -m json.tool 2>/dev/null || true
|
||||
if curl -fsS "http://127.0.0.1:$PORT/system_stats" >/dev/null 2>&1; then
|
||||
log "Server is running!"
|
||||
curl -fsS "http://127.0.0.1:$PORT/system_stats" | python3 -m json.tool 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
@@ -115,17 +265,22 @@ while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
done
|
||||
|
||||
if [ $ELAPSED -ge $MAX_WAIT ]; then
|
||||
echo "==> Server did not start within ${MAX_WAIT}s."
|
||||
echo " Check logs with: comfy launch (foreground) to see errors."
|
||||
err "Server did not start within ${MAX_WAIT}s."
|
||||
err "Inspect log: $LOG_FILE"
|
||||
err "Or run foreground: $COMFY_BIN launch -- --port $PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Setup complete!"
|
||||
echo " Server: http://127.0.0.1:8188"
|
||||
echo " Web UI: http://127.0.0.1:8188 (open in browser)"
|
||||
echo " Stop: comfy stop"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo " - Download a model: comfy model download --url <URL> --relative-path models/checkpoints"
|
||||
echo " - Run a workflow: python3 scripts/run_workflow.py --workflow <file.json> --args '{...}'"
|
||||
log ""
|
||||
log "Setup complete!"
|
||||
log " Server: http://127.0.0.1:$PORT"
|
||||
log " Web UI: http://127.0.0.1:$PORT (open in browser)"
|
||||
log " Stop: $COMFY_BIN stop"
|
||||
log " Log: $LOG_FILE (kept until shell closes)"
|
||||
log ""
|
||||
log "Next steps:"
|
||||
log " - Download a model: $COMFY_BIN model download --url <URL> --relative-path models/checkpoints"
|
||||
log " - Run a workflow: python3 $SCRIPT_DIR/run_workflow.py --workflow <file.json> --args '{...}'"
|
||||
|
||||
# Disable trap on success path
|
||||
trap - EXIT
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user