Compare commits

..

336 Commits

Author SHA1 Message Date
alt-glitch add160fd1b chore: remove dead _save_oversized_tool_result after merge
Superseded by maybe_persist_tool_result from tools/tool_result_storage.
Function had zero call sites — only its own test suite referenced it.
2026-04-07 01:35:31 +00:00
alt-glitch 51cf4e0bbc fix: address PR review — alias expansion + L3 budget enforcement
- Add `shopt -s expand_aliases` to snapshot so aliases captured by
  `alias -p` actually work under `bash -c` (review comment #2)
- Pass threshold=0 in enforce_turn_budget() so L3 can force-persist
  results below the 50K default when aggregate budget is exceeded
  (review comment #3)
- Add regression test: 6x42K results (each under 50K) exceeding 200K
  budget are now correctly persisted
2026-04-07 01:33:05 +00:00
alt-glitch 72bd14e09d perf(environments): reduce per-command overhead in _before_execute hooks
- Daytona: skip refresh_data() API call unless sandbox was interrupted/errored
- Docker: cache _build_forward_env_args() to avoid re-reading .env every command
- All remote backends: TTL-based sync skip (5s) to avoid redundant dir walks
2026-04-07 01:33:05 +00:00
alt-glitch 2fe8fd8720 docs: replace L2/L3 jargon labels with descriptive comments
Expanded tool_result_storage.py module docstring to document the
three-level architecture. Replaced opaque L2/L3 labels at call
sites with self-describing comments.
2026-04-07 01:33:05 +00:00
alt-glitch ab35753c52 refactor(managed_modal): replace sentinel-key dicts with _ExecStartResult dataclass 2026-04-07 01:33:05 +00:00
alt-glitch 51bd4aecff fix: update stale _ModalProcessHandle/_DaytonaProcessHandle references in docstrings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:33:05 +00:00
alt-glitch bead55bbcb refactor(environments): extract _ThreadedProcessHandle base class
Eliminates ~50 lines of duplicated pipe+thread+poll boilerplate between
_ModalProcessHandle and _DaytonaProcessHandle. Both now use closures
passed to the shared _ThreadedProcessHandle in base.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:33:05 +00:00
alt-glitch 5c491734f8 chore: reset uv.lock to main to fix nix build
The lockfile had drifted from main (debugpy, exa-py, version bump)
causing atomicwrites to fail building in the nix sandbox due to
missing setuptools in pyproject-build-systems.
2026-04-07 01:32:47 +00:00
alt-glitch 454edc7771 perf(ssh): add mtime-based caching to file sync
SSH _before_execute() ran rsync unconditionally before every command,
adding ~2.3s overhead even when zero bytes were transferred. This was
80% of per-command latency (actual execution: ~0.6s).

Add (mtime, size) caching — matching the pattern Modal and Daytona
already use — to skip rsync when local files haven't changed:

- Per-file mtime+size check for credential files
- Directory fingerprint (set of relpath/mtime/size tuples) for skills
- --delete flag on skills rsync to prune uninstalled skills
- Track created remote dirs to avoid redundant mkdir -p calls
- Cache invalidation on rsync failure (remote may have been wiped)
- force=True parameter as escape hatch for debugging

Before: ~3s per SSH command (2.3s rsync + 0.6s execution)
After:  ~0.6s per SSH command (mtime check + execution)
SSH test suite: 134s → 50s
2026-04-07 01:32:47 +00:00
alt-glitch 49d1390b40 fix(environments): move CWD tracking from remote file to in-band stdout
Previously, _wrap_command() wrote pwd to a file on the remote (container,
sandbox, SSH host), then _update_cwd_from_file() read it back via another
_run_bash() call. On Modal/Daytona this was a full API round-trip just to
read 20 bytes.

Now the wrapping template echoes the cwd to stdout with markers:
  printf '\n__HERMES_CWD__%s__HERMES_CWD__\n' "$(pwd -P)"

_extract_cwd_from_output() parses it from the output already in memory.
Zero extra round-trips on any backend. The cwdfile, _read_file_in_env(),
and per-backend overrides are all deleted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:32:47 +00:00
alt-glitch 7046d9b834 feat(agent_loop): add L2+L3 tool result persistence to eval path
- Add _tool_result_storage_dir to HermesAgentLoop.__init__
- Apply maybe_persist_tool_result() before tool message append
- Add enforce_turn_budget() after all tool calls in a turn
- Both wrapped in try/except (best-effort in eval path)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:32:47 +00:00
alt-glitch acbbdc05d4 feat(run_agent): replace 100K truncation with persist-to-disk (L2+L3)
Layer 2: Replace destructive head-truncation with maybe_persist_tool_result()
in both concurrent and sequential tool execution paths. Large results are
now written to ~/.hermes/sessions/{id}/tool-results/ with a 2KB preview
in context. Model can read_file the persisted path for full content.

Layer 3: Add enforce_turn_budget() after all tool results in a turn.
If aggregate exceeds 200K chars, persist largest results first until
under budget. Runs after concurrent futures.wait() (single-threaded).

Callbacks still receive full untruncated results before persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:32:47 +00:00
alt-glitch 9cbfa1309b feat(tools): add per-tool result size thresholds and search_files output cap
Declares max_result_size_chars on each tool registration so the persistence
layer can apply per-tool limits instead of the global 50K default. Adds a
Layer 1 output cap inside search_tool() to prevent context overflow, and
adds a schema maximum of 10000 to the search_files limit parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:31:48 +00:00
alt-glitch 3dfce74099 feat(tools): add tool result persistence module + registry support
Add tools/tool_result_storage.py implementing Layer 2 (per-result) and
Layer 3 (per-turn budget) persistence for large tool outputs. Results
exceeding thresholds are written to disk with a <persisted-output>
preview block replacing the inline content. Extend ToolEntry and
ToolRegistry with max_result_size_chars for per-tool threshold control.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:48 +00:00
alt-glitch 431262f9a5 test(daytona): update unit tests for unified execution model
- Update execute tests to account for init_session during __init__
- Fix CWD resolution tests for cwdfile reads
- Patch is_interrupted at base module level (where _wait_for_process uses it)
- Update stdin heredoc test for new call pattern
- 27/27 Daytona unit tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:48 +00:00
alt-glitch ec43496d5a refactor(environments): delete persistent_shell.py + dead code — Phase 8
- DELETE persistent_shell.py entirely (277 lines removed)
- Remove _SHELL_NOISE_SUBSTRINGS, _clean_shell_noise, _extract_fenced_output
  from local.py (unused after fence marker removal)
- Adapt ManagedModalEnvironment to use BaseEnvironment + _wrap_command()
  while keeping its own HTTP-based execute()
- Remove _OUTPUT_FENCE constant

42/42 tests passing across all testable backends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:48 +00:00
alt-glitch ef8a985ee3 refactor(environments): migrate Modal + Daytona to unified model — Phase 5+6
ModalEnvironment:
- Create _ModalProcessHandle adapter (async SDK → ProcessHandle protocol)
- Routes async sandbox.exec through thread + OS pipe for stdout
- Remove BaseModalExecutionEnvironment inheritance, use BaseEnvironment
- Remove _start_modal_exec/_poll_modal_exec/_cancel_modal_exec
- Move file sync to _before_execute hook
- Preserve _AsyncWorker, sandbox lifecycle, snapshot management

DaytonaEnvironment:
- Create _DaytonaProcessHandle adapter (blocking SDK → ProcessHandle)
- Preserves shell timeout wrapper (SDK timeout unreliable)
- Add _run_bash with heredoc stdin embedding
- Move file sync to _before_execute hook
- Preserve sandbox lifecycle, persistent resume logic

ManagedModal left unchanged (HTTP-based, keeps modal_common.py dep).
42/42 local tests passing. SDK backends untested (require credentials).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:41 +00:00
alt-glitch 0482133559 refactor(terminal_tool): remove persistent shell params from factory
- Remove persistent= from LocalEnvironment creation
- Remove persistent= from SSHEnvironment creation
- Container persistent_filesystem params unchanged (different concept)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:34 +00:00
alt-glitch 637bbbee7e refactor(environments): migrate Docker + SSH to unified model — Phase 2+4
DockerEnvironment:
- Add _run_bash/_run_bash_login, extract _build_forward_env_args()
- Remove execute() override with duplicate timeout/interrupt loop
- Remove -w flag (CWD handled by wrapping template)
- Call init_session() after container creation
- 42/42 tests passing on debian:bookworm-slim (21.7s)

SSHEnvironment:
- Remove PersistentShellMixin inheritance entirely
- Remove all IPC methods: _read_temp_files, _kill_shell_children,
  _cleanup_temp_files, _spawn_shell_process, _execute_oneshot
- Add _run_bash/_run_bash_login with shlex.quote for SSH transport
- Override _read_file_in_env with capture_output=True to suppress
  SSH connection warnings (post-quantum key exchange etc.)
- Move _sync_skills_and_credentials to _before_execute hook
- Remove persistent parameter
- 42/42 tests passing on SSH (173s, no more polling overhead)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:34 +00:00
alt-glitch cd814392ae refactor(environments): migrate SingularityEnvironment to unified model — Phase 3
- Add _run_bash/_run_bash_login, remove execute() override
- Remove --pwd flag (CWD handled by wrapping template)
- Remove is_interrupted import (handled by BaseEnvironment)
- Call init_session() after instance start

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:29 +00:00
alt-glitch fe2e1c16c6 refactor(environments): unify execution layer — Phase 0+1 (base + local)
Add spawn-per-call execution model to BaseEnvironment:
- ProcessHandle protocol for backend abstraction
- Shell snapshot creation (bash -l once, capture env to file)
- Command wrapping template (source snapshot + cd + eval + pwd tracking)
- Unified _wait_for_process with interrupt/timeout handling
- CWD tracking via cwdfile read after process exit

Migrate LocalEnvironment to unified model:
- Remove PersistentShellMixin inheritance
- Implement _run_bash (Popen + process group kill)
- Remove fence markers, oneshot method, shell noise cleanup
- Session snapshot captures env vars, functions, aliases

New test capabilities validated:
- CWD persists across execute() calls (was 37% manual cd prefix)
- stdin_data piping works
- Exit codes preserved through wrapper
- Single quotes survive eval escaping
- Snapshot fallback works when creation fails

42/42 tests passing on local backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:31:08 +00:00
Teknium 0e336b0e71 fix: backfill codex stream output from output_item.done events (#5689)
Salvages the core fix from PR #5673 (egerev) onto current main.

The chatgpt.com/backend-api/codex endpoint streams valid output items
via response.output_item.done events, but the OpenAI SDK's
get_final_response() returns an empty output list. This caused every
Codex response to be rejected as invalid.

Fix: collect output_item.done events during streaming and backfill
response.output when get_final_response() returns empty. Falls back
to synthesizing from text deltas when no done events were received.

Also moves the synthesis logic from the validation loop (too late, from
#5681) into _run_codex_stream() (before the response leaves the
streaming function), and simplifies the validation to just log
diagnostics since recovery now happens upstream.

Co-authored-by: Egor <egerev@users.noreply.github.com>
2026-04-06 18:19:30 -07:00
Grateful Dave e5aaa38ca7 fix: sync openai-codex pool entry from ~/.codex/auth.json on exhaustion (#5610)
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When the Codex CLI (or another Hermes profile) refreshes its token, the
pool entry's refresh_token becomes stale. Subsequent refresh attempts
fail with invalid_grant, and the entry enters a 24-hour exhaustion
cooldown with no recovery path.

This mirrors the existing _sync_anthropic_entry_from_credentials_file()
pattern: when an openai-codex entry is exhausted, compare its
refresh_token against ~/.codex/auth.json and sync the fresh pair if
they differ.

Fixes the common scenario where users run 'codex login' to refresh
their token externally and Hermes never picks it up.

Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>
2026-04-06 18:16:56 -07:00
Teknium dc4c07ed9d fix: codex OAuth credential pool disconnect + expired token import (#5681)
Three bugs causing OpenAI Codex sessions to fail silently:

1. Credential pool vs legacy store disconnect: hermes auth and hermes
   model store device_code tokens in the credential pool, but
   get_codex_auth_status(), resolve_codex_runtime_credentials(), and
   _model_flow_openai_codex() only read from the legacy provider state.
   Fresh pool tokens were invisible to the auth status checks and model
   selection flow.

2. _import_codex_cli_tokens() imported expired tokens from ~/.codex/
   without checking JWT expiry. Combined with _login_openai_codex()
   saying 'Login successful!' for expired credentials, users got stuck
   in a loop of dead tokens being recycled.

3. _login_openai_codex() accepted expired tokens from
   resolve_codex_runtime_credentials() without validating expiry before
   telling the user login succeeded.

Fixes:
- get_codex_auth_status() now checks credential pool first, falls back
  to legacy provider state
- _model_flow_openai_codex() uses pool-aware auth status for token
  retrieval when fetching model lists
- _import_codex_cli_tokens() validates JWT exp claim, rejects expired
- _login_openai_codex() verifies resolved token isn't expiring before
  accepting existing credentials
- _run_codex_stream() logs response.incomplete/failed terminal events
  with status and incomplete_details for diagnostics
- Codex empty output recovery: captures streamed text during streaming
  and synthesizes a response when get_final_response() returns empty
  output (handles chatgpt.com backend-api edge cases)
2026-04-06 18:10:33 -07:00
Teknium 8cf013ecd9 fix: replace stale 'hermes login' refs with 'hermes auth' + fix credential removal re-seeding (#5670)
Two fixes:

1. Replace all stale 'hermes login' references with 'hermes auth' across
   auth.py, auxiliary_client.py, delegate_tool.py, config.py, run_agent.py,
   and documentation. The 'hermes login' command was deprecated; 'hermes auth'
   now handles OAuth credential management.

2. Fix credential removal not persisting for singleton-sourced credentials
   (device_code for openai-codex/nous, hermes_pkce for anthropic).
   auth_remove_command already cleared env vars for env-sourced credentials,
   but singleton credentials stored in the auth store were re-seeded by
   _seed_from_singletons() on the next load_pool() call. Now clears the
   underlying auth store entry when removing singleton-sourced credentials.
2026-04-06 17:17:57 -07:00
Teknium adb418fb53 fix: cross-platform browser test path separators
Use os.path.join for Windows install path so test passes on Linux
(os.path.join uses / on Linux, \ on Windows).
2026-04-06 16:54:16 -07:00
jtuki 57abc99315 feat(gateway): add per-group access control for Feishu
Add fine-grained authorization policies per Feishu group chat via
platforms.feishu.extra configuration.

- Add global bot-level admins that bypass all group restrictions
- Add per-group policies: open, allowlist, blacklist, admin_only, disabled
- Add default_group_policy fallback for chats without explicit rules
- Thread chat_id through group message gate for per-chat rule selection
- Match both open_id and user_id for backward compatibility
- Preserve existing FEISHU_ALLOWED_USERS / FEISHU_GROUP_POLICY behavior
- Add focused regression tests for all policy modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:16 -07:00
jtuki 18727ca9aa refactor(gateway): simplify Feishu websocket config helpers
Consolidate coercion functions, extract loop readiness check, and deduplicate test mock setup to improve maintainability without changing behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:16 -07:00
jtuki 157d6184e3 fix(gateway): make Feishu websocket overrides effective at runtime
Reapply local reconnect and ping settings after the Feishu SDK refreshes its client config so user-provided websocket tuning actually takes effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:16 -07:00
jtuki ea31d9077c feat(gateway): add Feishu websocket ping timing overrides
Allow Feishu websocket keepalive timing to be configured via platform
extra config so disconnects can be detected faster in unstable networks.

New optional extra settings:
- ws_ping_interval
- ws_ping_timeout

These values are applied only when explicitly configured. Invalid values
fall back to the websocket library defaults by leaving the options unset.

This complements the reconnect timing settings added previously and helps
reduce total recovery time after network interruptions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:16 -07:00
jtuki 7d0bf15121 feat(gateway): add configurable Feishu websocket reconnect timing
Allow users to configure websocket reconnect behavior via platform extra
config to reduce reconnect latency in production environments.

The official Feishu SDK defaults to:
- First reconnect: random jitter 0-30 seconds
- Subsequent retries: 120 second intervals

This can cause 20-30 second delays before reconnection after network
interruptions. This commit makes these values configurable while keeping
the SDK defaults for backward compatibility.

Configuration via ~/.hermes/config.yaml:
```yaml
platforms:
  feishu:
    extra:
      ws_reconnect_nonce: 0        # Disable first-reconnect jitter (default: 30)
      ws_reconnect_interval: 3     # Retry every 3 seconds (default: 120)
```

Invalid values (negative numbers, non-integers) fall back to SDK defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:16 -07:00
jtuki 7cf4bd06bf fix(gateway): fix Feishu reconnect message drops and shutdown hang
This commit fixes two critical bugs in the Feishu adapter that affect
message reliability and process lifecycle.

**Bug Fix 1: Intermittent Message Drops**

Root cause: Event handler was created once in __init__ and reused across
reconnects, causing callbacks to capture stale loop references. When the
adapter disconnected and reconnected, old callbacks continued firing with
invalid loop references, resulting in dropped messages with warnings:
"[Feishu] Dropping inbound message before adapter loop is ready"

Fix:
- Rebuild event handler on each connect (websocket/webhook)
- Clear handler on disconnect
- Ensure callbacks always capture current valid loop
- Add defensive loop.is_closed() checks with getattr for test compatibility
- Unify webhook dispatch path to use same loop checks as websocket mode

**Bug Fix 2: Process Hangs on Ctrl+C / SIGTERM**

Root cause: Feishu SDK's websocket client runs in a background thread with
an infinite _select() loop that never exits naturally. The thread was never
properly joined on disconnect, causing processes to hang indefinitely after
Ctrl+C or gateway stop commands.

Fix:
- Store reference to thread-local event loop (_ws_thread_loop)
- On disconnect, cancel all tasks in thread loop and stop it gracefully
  via call_soon_threadsafe()
- Await thread future with 10s timeout
- Clean up pending tasks in thread's finally block before closing loop
- Add detailed debug logging for disconnect flow

**Additional Improvements:**
- Add regression tests for disconnect cleanup and webhook dispatch
- Ensure all event callbacks check loop readiness before dispatching

Tested on Linux with websocket mode. All Feishu tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:16 -07:00
Ruzzgar abd24d381b Implement comprehensive browser path discovery for Windows 2026-04-06 16:54:16 -07:00
Tianxiao 8a29b49036 fix(cli): handle CJK wide chars in TUI input height 2026-04-06 16:54:16 -07:00
kshitijk4poor 05f9267938 fix(matrix): hard-fail E2EE when python-olm missing + stable MATRIX_DEVICE_ID
Two issues caused Matrix E2EE to silently not work in encrypted rooms:

1. When matrix-nio is installed without the [e2e] extra (no python-olm /
   libolm), nio.crypto.ENCRYPTION_ENABLED is False and client.olm is
   never initialized. The adapter logged warnings but returned True from
   connect(), so the bot appeared online but could never decrypt messages.
   Now: check_matrix_requirements() and connect() both hard-fail with a
   clear error message when MATRIX_ENCRYPTION=true but E2EE deps are
   missing.

2. Without a stable device_id, the bot gets a new device identity on each
   restart. Other clients see it as "unknown device" and refuse to share
   Megolm session keys. Now: MATRIX_DEVICE_ID env var lets users pin a
   stable device identity that persists across restarts and is passed to
   nio.AsyncClient constructor + restore_login().

Changes:
- gateway/platforms/matrix.py: add _check_e2ee_deps(), hard-fail in
  connect() and check_matrix_requirements(), MATRIX_DEVICE_ID support
  in constructor + restore_login
- gateway/config.py: plumb MATRIX_DEVICE_ID into platform extras
- hermes_cli/config.py: add MATRIX_DEVICE_ID to OPTIONAL_ENV_VARS

Closes #3521
2026-04-06 16:54:16 -07:00
tymrtn 40527ff5e3 fix(auth): actionable error message when Codex refresh token is reused
When the Codex CLI (or VS Code extension) consumes a refresh token before
Hermes can use it, Hermes previously surfaced a generic 401 error with no
actionable guidance.

- In `refresh_codex_oauth_pure`: detect `refresh_token_reused` from the
  OAuth endpoint and raise an AuthError explaining the cause and the exact
  steps to recover (run `codex` to refresh, then `hermes login`).
- In `run_agent.py`: when provider is `openai-codex` and HTTP 401 is
  received, show Codex-specific recovery steps instead of the generic
  "check your API key" message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:50:10 -07:00
Zainan Victor Zhou 190471fdc0 docs: use HERMES_HOME in google-workspace skill examples
- avoid hard-coded ~/.hermes paths in the setup and API shorthands
- prefer HERMES_HOME with a sane default to /Users/peteradams/.hermes
- keep the examples aligned with profile-aware Hermes installs
2026-04-06 16:50:07 -07:00
Zainan Victor Zhou 83df001d01 fix: allow google-workspace skill scripts to run directly
- fall back to adding the repo root to sys.path when hermes_constants is not importable
- fixes direct execution of setup.py and google_api.py from the repo checkout
- keeps the upstream PR scoped to the google-workspace compatibility fix
2026-04-06 16:50:07 -07:00
WAXLYY 1c0183ec71 fix(gateway): sanitize media URLs in base platform logs 2026-04-06 16:50:05 -07:00
KangYu b26e85bf9d Fix compaction summary retries for temperature-restricted models 2026-04-06 16:49:57 -07:00
charliekerfoot e9b5864b3f fix: multiple platform adaptors concurrency 2026-04-06 16:49:54 -07:00
WAXLYY c1818b7e9e fix(tools): redact query secrets in send_message errors 2026-04-06 16:49:52 -07:00
Neri Cervin f3ae2491a3 fix: detect correct message type from file mime instead of blanket DOCUMENT
Images need PHOTO for vision, audio needs VOICE for STT,
and other files get DOCUMENT for text inlining.
2026-04-06 16:49:45 -07:00
Neri Cervin 3282b7066c fix(mattermost): set message type to DOCUMENT when post has file attachments
The Mattermost adapter downloads file attachments correctly but
never updates msg_type from TEXT to DOCUMENT. This means the
document enrichment block in gateway/run.py (which requires
MessageType.DOCUMENT) never executes — text files are not
inlined, and the agent is never notified about attached files.

The user sends a file, the adapter downloads it to the local
cache, but the agent sees an empty message and responds with
'I didn't receive any file'.

Set msg_type to DOCUMENT when file_ids is non-empty, matching
the behavior of the Telegram and Discord adapters.
2026-04-06 16:49:45 -07:00
ryanautomated 0f9aa57069 fix: silent memory flush failure on /new and /resume commands
The _async_flush_memories() helper accepts (session_id) but both the
/new and /resume handlers passed two arguments (session_id, session_key).
The TypeError was silently swallowed at DEBUG level, so memory extraction
never ran when users typed /new or /resume.

One call site (the session expiry watcher) was already fixed in 9c96f669,
but /new and /resume were missed.

- gateway/run.py:3247 — remove stray session_key from /new handler
- gateway/run.py:4989 — remove stray session_key from /resume handler
- tests/gateway/test_resume_command.py:222 — update test assertion
2026-04-06 16:49:42 -07:00
Myeongwon Choi ea16949422 fix(cron): suppress delivery when [SILENT] appears anywhere in response
Previously the scheduler checked startswith('[SILENT]'), so agents that
appended [SILENT] after an explanation (e.g. 'N items filtered.\n\n[SILENT]')
would still trigger delivery.

Change the check to 'in' so the marker is caught regardless of position.
Add test_silent_trailing_suppresses_delivery to cover this case.
2026-04-06 16:49:40 -07:00
charliekerfoot 3b4dfc8e22 fix(tools): portable base64 encoding for image reading on macOS 2026-04-06 16:49:32 -07:00
KangYu 77610961be Lower Telegram fallback activation log to info 2026-04-06 16:49:30 -07:00
Simon Brumfield e131f13662 fix(doctor): use recall_mode instead of memory_mode on HonchoClientConfig 2026-04-06 16:49:27 -07:00
dagbs e7698521e7 fix(openviking): add atexit safety net for session commit
Ensures pending sessions are committed on process exit even if
shutdown_memory_provider is never called (gateway crash, SIGKILL,
or exception in _async_flush_memories preventing shutdown).

Also reorders on_session_end to wait for the pending sync thread
before checking turn_count, so the last turn's messages are flushed.

Based on PR #4919 by dagbs.
2026-04-06 16:45:53 -07:00
Teknium f071b1832a docs: document rich requires_env format and install-time prompting
Updates the plugin build guide and features page to reflect the
interactive env var prompting added in PR #5470. Documents the rich
manifest format (name/description/url/secret) alongside the simple
string format.
2026-04-06 16:43:42 -07:00
Nick 4f03b9a419 feat(webhook): add {__raw__} template token and thread_id passthrough for forum topics
- {__raw__} in webhook prompt templates dumps the full JSON payload (truncated at 4000 chars)
- _deliver_cross_platform now passes thread_id/message_thread_id from deliver_extra as metadata, enabling Telegram forum topic delivery
- Tests for both features
2026-04-06 16:42:52 -07:00
Teknium 631d159864 fix: use display_hermes_home() for profile-aware paths in plugin env prompts
Follow-up to PR #5470. Replaces hardcoded ~/.hermes/.env references with
display_hermes_home() for correct behavior under profiles. Also updates
PluginManifest.requires_env type hint to List[Union[str, Dict[str, Any]]]
to document the rich format introduced in #5470.
2026-04-06 16:40:15 -07:00
kshitijk4poor 9201370c7e feat(plugins): prompt for required env vars during hermes plugins install
Read requires_env from plugin.yaml after install and interactively
prompt for any missing environment variables, saving them to
~/.hermes/.env.

Supports two manifest formats:

  Simple (backwards-compatible):
    requires_env:
      - MY_API_KEY

  Rich (with metadata):
    requires_env:
      - name: MY_API_KEY
        description: "API key for Acme"
        url: "https://acme.com/keys"
        secret: true

Already-set variables are skipped. Empty input skips gracefully.
Secret values use getpass (hidden input). Ctrl+C aborts remaining
prompts without error.
2026-04-06 16:37:53 -07:00
Teknium 539629923c docs(llm-wiki): add Obsidian Headless setup for servers (#5660)
Adds obsidian-headless (npm) setup guide to the Obsidian Integration
section — Node 22+, ob login, sync-create-remote, sync-setup, systemd
service for continuous background sync. Covers the full headless
workflow for agents running on servers syncing to Obsidian desktop on
other devices.
2026-04-06 16:37:14 -07:00
Siddharth Balyan e651e04100 fix(nix): read version, regen uv.lock, fix packages.nix to add hermes_logging (#5651)
* - read version from pyproject for nix
- regen uv.lock
- add hermes_logging to packages.nix

* fix secret regen w/ sops
2026-04-07 04:21:19 +05:30
Siddharth Balyan 7b129636f0 feat(tools): add Firecrawl cloud browser provider (#5628)
* feat(tools): add Firecrawl cloud browser provider

Adds Firecrawl (https://firecrawl.dev) as a cloud browser provider
alongside Browserbase and Browser Use. All browser tools route through
Firecrawl's cloud browser via CDP when selected.

- tools/browser_providers/firecrawl.py — FirecrawlProvider
- tools/browser_tool.py — register in _PROVIDER_REGISTRY
- hermes_cli/tools_config.py — add to onboarding provider picker
- hermes_cli/setup.py — add to setup summary
- hermes_cli/config.py — add FIRECRAWL_BROWSER_TTL config
- website/docs/ — browser docs and env var reference

Based on #4490 by @developersdigest.

Co-Authored-By: Developers Digest <124798203+developersdigest@users.noreply.github.com>

* refactor: simplify FirecrawlProvider.emergency_cleanup

Use self._headers() and self._api_url() instead of duplicating
env-var reads and header construction.

* fix: recognize Firecrawl in subscription browser detection

_resolve_browser_feature_state() now handles "firecrawl" as a direct
browser provider (same pattern as "browser-use"), so hermes setup
summary correctly shows "Browser Automation (Firecrawl)" instead of
misreporting as "Local browser".

Also fixes test_config_version_unchanged assertion (11 → 12).

---------

Co-authored-by: Developers Digest <124798203+developersdigest@users.noreply.github.com>
2026-04-07 02:35:26 +05:30
Teknium 150f70f821 feat(skills): add skill config interface + llm-wiki skill (#5635)
Skills can now declare config.yaml settings via metadata.hermes.config
in their SKILL.md frontmatter. Values are stored under skills.config.*
namespace, prompted during hermes config migrate, shown in hermes config
show, and injected into the skill context at load time.

Also adds the llm-wiki skill (Karpathy's LLM Wiki pattern) as the first
skill to use the new config interface, declaring wiki.path.

Skill config interface (new):
- agent/skill_utils.py: extract_skill_config_vars(), discover_all_skill_config_vars(),
  resolve_skill_config_values(), SKILL_CONFIG_PREFIX
- agent/skill_commands.py: _inject_skill_config() injects resolved values
  into skill messages as [Skill config: ...] block
- hermes_cli/config.py: get_missing_skill_config_vars(), skill config
  prompting in migrate_config(), Skill Settings in show_config()

LLM Wiki skill (skills/research/llm-wiki/SKILL.md):
- Three-layer architecture (raw sources, wiki pages, schema)
- Three operations (ingest, query, lint)
- Session orientation, page thresholds, tag taxonomy, update policy,
  scaling guidance, log rotation, archiving workflow

Docs: creating-skills.md, configuration.md, skills.md, skills-catalog.md

Closes #5100
2026-04-06 13:49:13 -07:00
Mikita Lisavets 29b5ec2555 fix: clear session-scoped model after session reset 2026-04-06 13:20:01 -07:00
Mikita Lisavets 9afb9a6cb2 fix: clear session-scoped model overrides during session reset 2026-04-06 13:20:01 -07:00
donrhmexe 2c814d7b5d fix: /model --global writes model.name instead of model.default
The canonical config key for model name is model.default (used by setup,
auth, runtime_provider, profile list, and CLI startup). But /model --global
wrote to model.name in both gateway and CLI paths.

This caused:
- hermes profile list showing the old model (reads model.default)
- Gateway restart reverting to the old model (_resolve_gateway_model reads model.default)
- CLI startup using the old model (main.py reads model.default)

The only reason it appeared to work in Telegram was the cached agent
staying alive with the in-place switch.

Fix: change all 3 write/read sites to use model.default.
2026-04-06 13:20:01 -07:00
BongSuCHOI ad567c9a8f fix: subagent toolset inheritance when parent enabled_toolsets is None
When parent_agent.enabled_toolsets is None (the default, meaning all tools
are enabled), subagents incorrectly fell back to DEFAULT_TOOLSETS
(['terminal', 'file', 'web']) instead of inheriting the parent's full
toolset.

Root cause:
- Line 188 used 'or' fallback: None or DEFAULT_TOOLSETS evaluates to
  DEFAULT_TOOLSETS
- Line 192 checked truthiness: None is falsy, falling through to else

Fix:
- Use 'is not None' checks instead of truthiness
- When enabled_toolsets is None, derive effective toolsets from
  parent_agent.valid_tool_names via the tool registry

Fixes the bug introduced in f75b1d21b and repeated in e5d14445e (PR #3269).
2026-04-06 13:20:01 -07:00
donrhmexe ff655de481 fix: model alias fallback uses authenticated providers instead of hardcoded openrouter/nous
When an alias like 'claude' can't be resolved on the current provider,
_resolve_alias_fallback() tries other providers. Previously it hardcoded
('openrouter', 'nous') — so '/model claude' on z.ai would resolve to
openrouter even if the user doesn't have openrouter credentials but does
have anthropic.

Now the fallback uses the user's actual authenticated providers (detected
via list_authenticated_providers which is backed by the models.dev
in-memory cache). If no authenticated providers are found, falls back to
the old ('openrouter', 'nous') for backwards compatibility.

New helper: get_authenticated_provider_slugs() returns just the slug
strings from list_authenticated_providers().
2026-04-06 13:20:01 -07:00
Ayman Kamal 96f85b03cd fix: handle launchctl kickstart exit code 113 in launchd_start()
launchctl kickstart returns exit code 113 ("Could not find service") when
the plist exists but the job hasn't been bootstrapped into the runtime domain.
The existing recovery path only caught exit code 3 ("unloaded"), causing an
unhandled CalledProcessError.

Exit code 113 means the same thing practically -- the service definition needs
bootstrapping before it can be kicked. Add it to the same recovery path that
already handles exit 3, matching the existing pattern in launchd_stop().

Follow-up: add a unit test covering the 113 recovery path.
2026-04-06 13:20:01 -07:00
Dusk1e 1a2f109d8e Ensure atomic writes for gateway channel directory cache to prevent truncation 2026-04-06 13:20:01 -07:00
Mariano A. Nicolini af9a9f773c fix(security): sanitize workdir parameter in terminal tool backends
Shell injection via unquoted workdir interpolation in docker, singularity,
and SSH backends.  When workdir contained shell metacharacters (e.g.
~/;id), arbitrary commands could execute.

Changes:
- Add shlex.quote() at each interpolation point in docker.py,
  singularity.py, and ssh.py with tilde-aware quoting (keep ~
  unquoted for shell expansion, quote only the subpath)
- Add _validate_workdir() allowlist in terminal_tool.py as
  defense-in-depth before workdir reaches any backend

Original work by Mariano A. Nicolini (PR #5620).  Salvaged with fixes
for tilde expansion (shlex.quote breaks cd ~/path) and replaced
incomplete deny-list with strict character allowlist.

Co-authored-by: Mariano A. Nicolini <entropidelic@users.noreply.github.com>
2026-04-06 13:19:22 -07:00
Teknium 537a2b8bb8 docs: add WSL2 networking guide for local model servers (#5616)
Windows users running Hermes in WSL2 with model servers on the Windows
host hit 'connection refused' because WSL2's NAT networking means
localhost points to the VM, not Windows.

Covers:
- Mirrored networking mode (Win 11 22H2+) — makes localhost work
- NAT mode fallback using the host IP via ip route
- Per-server bind address table (Ollama, LM Studio, llama-server,
  vLLM, SGLang)
- Detailed Ollama Windows service config for OLLAMA_HOST
- Windows Firewall rules for WSL2 connections
- Quick verification steps
- Cross-reference from Troubleshooting section
2026-04-06 13:01:18 -07:00
Teknium 261e2ee862 fix: restore Path import in env_passthrough.py (removed by #5526)
The ContextVar migration removed 'from pathlib import Path' but Path
is still used in _load_config_passthrough(). Without this import,
config-based env passthrough would raise NameError.
2026-04-06 12:42:16 -07:00
Awsh1 878b1d3d33 fix(cron): harden scheduler against path traversal and env leaks
Cherry-picked from PR #5503 by Awsh1.

- Validate ALL script paths (absolute, relative, tilde) against scripts_dir boundary
- Add API-boundary validation in cronjob_tools.py
- Move os.environ injections inside try block so finally cleanup always runs
- Comprehensive regression tests for path containment bypass
2026-04-06 12:42:16 -07:00
Dusk1e 7d0953d6ff security(gateway): isolate env/credential registries using ContextVars 2026-04-06 12:42:16 -07:00
Teknium da02a4e283 fix: auxiliary client payment fallback — retry with next provider on 402 (#5599)
When a user runs out of OpenRouter credits and switches to Codex (or any
other provider), auxiliary tasks (compression, vision, web_extract) would
still try OpenRouter first and fail with 402.  Two fixes:

1. Payment fallback in call_llm(): When a resolved provider returns HTTP 402
   or a credit-related error, automatically retry with the next available
   provider in the auto-detection chain.  Skips the depleted provider and
   tries Nous → Custom → Codex → API-key providers.

2. Remove hardcoded OpenRouter fallback: The old code fell back specifically
   to OpenRouter when auto/custom resolution returned no client.  Now falls
   back to the full auto-detection chain, which handles any available
   provider — not just OpenRouter.

Also extracts _get_provider_chain() as a shared function (replaces inline
tuple in _resolve_auto and the new fallback), built at call time so test
patches on _try_* functions remain visible.

Adds 16 tests covering _is_payment_error(), _get_provider_chain(),
_try_payment_fallback(), and call_llm() integration with 402 retry.
2026-04-06 12:41:40 -07:00
Teknium 8ffd44a6f9 feat(discord): register skills as native slash commands via shared gateway logic (#5603)
Centralize the skill → slash command registration that Telegram already had
in commands.py so Discord uses the exact same priority system, filtering,
and cap enforcement:

  1. Core/built-in commands (never trimmed)
  2. Plugin commands (never trimmed)
  3. Skill commands (fill remaining slots, alphabetical, only tier trimmed)

Changes:

hermes_cli/commands.py:
  - Rename _TG_NAME_LIMIT → _CMD_NAME_LIMIT (32 chars shared by both platforms)
  - Rename _clamp_telegram_names → _clamp_command_names (generic)
  - Extract _collect_gateway_skill_entries() — shared plugin + skill
    collection with platform filtering, name sanitization, description
    truncation, and cap enforcement
  - Refactor telegram_menu_commands() to use the shared helper
  - Add discord_skill_commands() that returns (name, desc, cmd_key) triples
  - Preserve _sanitize_telegram_name() for Telegram-specific name cleaning

gateway/platforms/discord.py:
  - Call discord_skill_commands() from _register_slash_commands()
  - Create app_commands.Command per skill entry with cmd_key callback
  - Respect 100-command global Discord limit
  - Log warning when skills are skipped due to cap

Backward-compat aliases preserved for _TG_NAME_LIMIT and
_clamp_telegram_names.

Tests: 9 new tests (7 Discord + 2 backward-compat), 98 total pass.

Inspired by PR #5498 (sprmn24). Closes #5480.
2026-04-06 12:09:36 -07:00
Julien Talbot 92c19924a9 feat: add xAI prompt caching via x-grok-conv-id header
When using xAI's API directly (base_url contains x.ai), send the
x-grok-conv-id header set to the Hermes session_id. This routes
consecutive requests to the same server, maximizing automatic
prompt cache hits.

Ref: https://docs.x.ai/developers/advanced-api-usage/prompt-caching
2026-04-06 12:06:33 -07:00
SHL0MS 0afa3a87d4 Merge pull request #5600 from SHL0MS/feat/p5js-skill
feat(skills): add p5js creative coding skill
2026-04-06 14:52:27 -04:00
Teknium 3d08a2fa1b fix: extract MEDIA: tags from cron delivery before sending (#5598)
The cron scheduler delivery path passed raw text including MEDIA: tags
to _send_to_platform(), so media attachments were delivered as literal
text instead of actual files. The send function already supports
media_files= but the cron path never used it.

Now calls BasePlatformAdapter.extract_media() to split media paths
from text before sending, matching the gateway's normal message flow.

Salvaged from PR #4877 by robert-hoffmann.
2026-04-06 11:42:44 -07:00
kshitijk4poor 5e88eb2ba0 fix(signal): implement send_image_file, send_voice, and send_video for MEDIA: tag delivery
The Signal adapter inherited base class defaults for send_image_file(),
send_voice(), and send_video() which only sent the file path as text
(e.g. '🖼️ Image: /tmp/chart.png') instead of actually delivering the file
as a Signal attachment.

When agent responses contain MEDIA:/path/to/file tags, the gateway
media pipeline extracts them and routes through these methods by file
type. Without proper overrides, image/audio/video files were never
actually delivered to Signal users.

Extract a shared _send_attachment() helper that handles all file
validation, size checking, group/DM routing, and RPC dispatch. The four
public methods (send_document, send_image_file, send_voice, send_video)
now delegate to this helper, following the same pattern used by WhatsApp
(_send_media_to_bridge) and Discord (_send_file_attachment).

The helper also uses a single stat() call with try/except FileNotFoundError
instead of the previous exists() + stat() two-syscall pattern, eliminating
a TOCTOU race. As a bonus, send_document() now gains the 100MB size check
that was previously missing (inconsistency with send_image).

Add 25 tests covering all methods plus MEDIA: tag extraction integration,
method-override guards, and send_document's new size check.

Fixes #5105
2026-04-06 11:41:34 -07:00
SHL0MS 17e2a27c51 feat(skills): add p5js creative coding skill
Production pipeline for interactive and generative visual art using p5.js.

Covers 7 modes: generative art, data visualization, interactive experiences,
animation/motion graphics, 3D scenes, image processing, and audio-reactive.

Includes:
- SKILL.md with creative standard, pipeline, and critical implementation notes
- 10 reference files covering core API, shapes, visual effects (noise, flow
  fields, particles, domain warp, attractors, L-systems, circle packing,
  bloom, reaction-diffusion), animation (easing, springs, state machines,
  scene transitions), typography, color systems, WebGL/3D/shaders,
  interaction, and comprehensive export pipeline
- Deterministic headless frame capture via Puppeteer (noLoop + redraw)
- ffmpeg render pipeline for MP4 video export
- Per-clip architecture for multi-scene video production
- Interactive viewer template with seed navigation and parameter controls
- Performance guidance: FES disable, Math.* hot loops, per-pixel budgets
- Addon library coverage: p5.brush, p5.grain, CCapture.js, p5.js-svg
- fxhash/Art Blocks generative platform conventions
- p5.js 2.0 migration guide (async setup, OKLCH, splineVertex, shader.modify)
- 13 documented common mistakes and troubleshooting patterns

17 files, ~5,900 lines.
2026-04-06 14:39:00 -04:00
kshitijk4poor 214e60c951 fix: sanitize Telegram command names to strip invalid characters
Telegram Bot API requires command names to contain only lowercase a-z,
digits 0-9, and underscores. Skill/plugin names containing characters
like +, /, @, or . caused set_my_commands to fail with
Bot_command_invalid.

Two-layer fix:
- scan_skill_commands(): strip non-alphanumeric/non-hyphen chars from
  cmd_key at source, collapse consecutive hyphens, trim edges, skip
  names that sanitize to empty string
- _sanitize_telegram_name(): centralized helper used by all 3 Telegram
  name generation sites (core commands, plugin commands, skill commands)
  with empty-name guard at each call site

Closes #5534
2026-04-06 11:27:28 -07:00
ClintonEmok f77be22c65 Fix #5211: Preserve dots in OpenCode Go model names
OpenCode Go model names with dots (minimax-m2.7, glm-4.5, kimi-k2.5)
were being mangled to hyphens (minimax-m2-7), causing HTTP 401 errors.

Two code paths were affected:
1. model_normalize.py: opencode-go was incorrectly in DOT_TO_HYPHEN_PROVIDERS
2. run_agent.py: _anthropic_preserve_dots() did not check for opencode-go

Fix:
- Remove opencode-go from _DOT_TO_HYPHEN_PROVIDERS (dots are correct for Go)
- Add opencode-go to _anthropic_preserve_dots() provider check
- Add opencode.ai/zen/go to base_url fallback check
- Add regression tests in tests/test_model_normalize.py

Co-authored-by: jacob3712 <jacob3712@users.noreply.github.com>
2026-04-06 11:25:06 -07:00
Teknium 582dbbbbf7 feat: add grok to TOOL_USE_ENFORCEMENT_MODELS for direct xAI usage (#5595)
Grok models (x-ai/grok-4.20-beta, grok-code-fast-1) now receive tool-use
enforcement guidance, steering them to actually call tools instead of
describing intended actions. Matches both OpenRouter (x-ai/grok-*) and
direct xAI API usage.
2026-04-06 11:22:07 -07:00
SHL0MS 0bac07ded3 Merge pull request #5588 from SHL0MS/feat/manim-skill-deep-expansion
docs(manim-video): add 5 new reference files — design thinking, updaters, paper explainer, decorations, production quality
2026-04-06 13:58:00 -04:00
SHL0MS a912cd4568 docs(manim-video): add 5 new reference files — design thinking, updaters, paper explainer, decorations, production quality
Five new reference files expanding the skill from rendering knowledge
into production methodology:

animation-design-thinking.md (161 lines):
  When to animate vs show static, concept decomposition into visual
  beats, pacing rules, narration sync, equation reveal strategies,
  architecture diagram patterns, common design mistakes.

updaters-and-trackers.md (260 lines):
  Deep ValueTracker mental model, lambda/time-based/always_redraw
  updaters, DecimalNumber and Variable live displays, animation-based
  updaters, 4 complete practical patterns (dot tracing, live area,
  connected diagram, parameter exploration).

paper-explainer.md (255 lines):
  Full workflow for turning research papers into animations. Audience
  selection, 5-minute template, pre-code gates (narration, scene list,
  style contract), equation reveal strategies, architecture diagram
  building, results animation, domain-specific patterns for ML/physics/
  biomedical papers.

decorations.md (202 lines):
  SurroundingRectangle, BackgroundRectangle, Brace, arrows (straight,
  curved, labeled), DashedLine, Angle/RightAngle, Cross, Underline,
  color highlighting workflows, annotation lifecycle pattern.

production-quality.md (190 lines):
  Pre-code, pre-render, post-render checklists. Text overlap prevention,
  spatial layout coordinate budget, max simultaneous elements, animation
  variety audit, tempo curve, color consistency, data viz minimums.

Total skill now: 14 reference files, 2614 lines.
2026-04-06 13:51:36 -04:00
Teknium cc7136b1ac fix: update Gemini model catalog + wire models.dev as live model source
Follow-up for salvaged PR #5494:
- Update model catalog to Gemini 3.x + Gemma 4 (drop deprecated 2.0)
- Add list_agentic_models() to models_dev.py with noise filter
- Wire models.dev into _model_flow_api_key_provider as primary source
  (static curated list serves as offline fallback)
- Add gemini -> google mapping in PROVIDER_TO_MODELS_DEV
- Fix Gemma 4 context lengths to 256K (models.dev values)
- Update auxiliary model to gemini-3-flash-preview
- Expand tests: 3.x catalog, context lengths, models.dev integration
2026-04-06 10:28:03 -07:00
Teknium 6dfab35501 feat(providers): add Google AI Studio (Gemini) as a first-class provider
Cherry-picked from PR #5494 by kshitijk4poor.
Adds native Gemini support via Google's OpenAI-compatible endpoint.
Zero new dependencies.
2026-04-06 10:28:03 -07:00
SHL0MS 85973e0082 fix(nous): don't use OAuth access_token as inference API key
When agent_key is missing from auth state (expired, not yet minted,
or mint failed silently), the fallback chain fell through to
access_token — an OAuth bearer token for the Nous portal API, not
an inference credential. The Nous inference API returns 404 because
the OAuth token is not a valid inference key.

Remove the access_token fallback so an empty agent_key correctly
triggers resolve_nous_runtime_credentials() to mint a fresh key.

Closes #5562
2026-04-06 10:04:02 -07:00
Austin Pickett eceb89b824 Merge pull request #4664 from NousResearch/fix/various-qa
fix: re-order providers, Quick Install
2026-04-06 08:35:34 -07:00
Austin Pickett 79aeaa97e6 fix: re-order providers,Quick Install, subscription polling 2026-04-06 11:16:07 -04:00
Teknium 6f1cb46df9 fix: register /queue, /background, /btw as native Discord slash commands (#5477)
These commands were defined in the central command registry and handled
by the gateway runner, but not registered as native Discord slash commands
via @tree.command(). This meant they didn't appear in Discord's slash
command picker UI.

Reported by community user — /queue worked on Telegram but not Discord.
2026-04-06 02:05:27 -07:00
Teknium 5747590770 fix: follow-up improvements for salvaged PR #5456
- SQLite write queue: thread-local connection pooling instead of
  creating+closing a new connection per operation
- Prefetch threads: join previous batch before spawning new ones to
  prevent thread accumulation on rapid queue_prefetch() calls
- Shutdown: join prefetch threads before stopping write queue
- Add 73 tests covering _Client HTTP payloads, _WriteQueue crash
  recovery & connection reuse, _build_overlay deduplication,
  RetainDBMemoryProvider lifecycle/tools/prefetch/hooks, thread
  accumulation guard, and reasoning_level heuristic
2026-04-06 02:00:55 -07:00
Alinxus ea8ec27023 fix(retaindb): make project optional, default to 'default' project 2026-04-06 02:00:55 -07:00
Alinxus 6df4860271 fix(retaindb): fix API routes, add write queue, dialectic, agent model, file tools
The previous implementation hit endpoints that do not exist on the RetainDB
API (/v1/recall, /v1/ingest, /v1/remember, /v1/search, /v1/profile/:p/:u).
Every operation was silently failing with 404. This rewrites the plugin against
the real API surface and adds several new capabilities.

API route fixes:
- Context query: POST /v1/context/query (was /v1/recall)
- Session ingest: POST /v1/memory/ingest/session (was /v1/ingest)
- Memory write: POST /v1/memory with legacy fallback to /v1/memories (was /v1/remember)
- Memory search: POST /v1/memory/search (was /v1/search)
- User profile: GET /v1/memory/profile/:userId (was /v1/profile/:project/:userId)
- Memory delete: DELETE /v1/memory/:id with fallback (was /v1/memory/:id, wrong base)

Durable write-behind queue:
- SQLite spool at ~/.hermes/retaindb_queue.db
- Turn ingest is fully async — zero blocking on the hot path
- Pending rows replay automatically on restart after a crash
- Per-row error marking with retry backoff

Background prefetch (fires at turn-end, ready for next turn-start):
- Context: profile + semantic query, deduped overlay block
- Dialectic synthesis: LLM-powered synthesis of what is known about the
  user for the current query, with dynamic reasoning level based on
  message length (low / medium / high)
- Agent self-model: persona, persistent instructions, working style
  derived from AGENT-scoped memories
- All three run in parallel daemon threads, consumed atomically at
  turn-start within the prefetch timeout budget

Agent identity seeding:
- SOUL.md content ingested as AGENT-scoped memories on startup
- Enables persistent cross-session agent self-knowledge

Shared file store tools (new):
- retaindb_upload_file: upload local file, optional auto-ingest
- retaindb_list_files: directory listing with prefix filter
- retaindb_read_file: fetch and decode text content
- retaindb_ingest_file: chunk + embed + extract memories from stored file
- retaindb_delete_file: soft delete

Built-in memory mirror:
- on_memory_write() now hits the correct write endpoint
2026-04-06 02:00:55 -07:00
MestreY0d4-Uninter 6c12999b8c fix: bridge tool-calls in copilot-acp adapter
Enable Hermes tool execution through the copilot-acp adapter by:
- Passing tool schemas and tool_choice into the ACP prompt text
- Instructing ACP backend to emit <tool_call>{...}</tool_call> blocks
- Parsing XML tool-call blocks and bare JSON fallback back into
  Hermes-compatible SimpleNamespace tool call objects
- Setting finish_reason='tool_calls' when tool calls are extracted
- Cleaning tool-call markup from response text

Fix duplicate tool call extraction when both XML block and bare JSON
regexes matched the same content (XML blocks now take precedence).

Cherry-picked from PR #4536 by MestreY0d4-Uninter. Stripped heuristic
fallback system (auto-synthesized tool calls from prose) and
Portuguese-language patterns — tool execution should be model-decided,
not heuristic-guessed.
2026-04-06 01:47:57 -07:00
kshitijk4poor d3d5b895f6 refactor: simplify _get_service_pids — dedupe systemd scopes, fix self-import, harden launchd parsing
- Loop over user/system scope args instead of duplicating the systemd block
- Call get_launchd_label() directly instead of self-importing from hermes_cli.gateway
- Validate launchd output by checking parts[2] matches expected label (skip header)
- Add race-condition assumption docstring
2026-04-06 00:09:06 -07:00
kshitijk4poor a2a9ad7431 fix: hermes update kills freshly-restarted gateway service
After restarting a service-managed gateway (systemd/launchd), the
stale-process sweep calls find_gateway_pids() which returns ALL gateway
PIDs via ps aux — including the one just spawned by the service manager.
The sweep kills it, leaving the user with a stopped gateway and a
confusing 'Restart manually' message.

Fix: add _get_service_pids() to query systemd MainPID and launchd PID
for active gateway services, then exclude those PIDs from the sweep.
Also add exclude_pids parameter to find_gateway_pids() and
kill_gateway_processes() so callers can skip known service-managed PIDs.

Adds 9 targeted tests covering:
- _get_service_pids() for systemd, launchd, empty, and zero-PID cases
- find_gateway_pids() exclude_pids filtering
- cmd_update integration: service PID not killed after restart
- cmd_update integration: manual PID killed while service PID preserved
2026-04-06 00:09:06 -07:00
Teknium 9c96f669a1 feat: centralized logging, instrumentation, hermes logs CLI, gateway noise fix (#5430)
Adds comprehensive logging infrastructure to Hermes Agent across 4 phases:

**Phase 1 — Centralized logging**
- New hermes_logging.py with idempotent setup_logging() used by CLI, gateway, and cron
- agent.log (INFO+) and errors.log (WARNING+) with RotatingFileHandler + RedactingFormatter
- config.yaml logging: section (level, max_size_mb, backup_count)
- All entry points wired (cli.py, main.py, gateway/run.py, run_agent.py)
- Fixed debug_helpers.py writing to ./logs/ instead of ~/.hermes/logs/

**Phase 2 — Event instrumentation**
- API calls: model, provider, tokens, latency, cache hit %
- Tool execution: name, duration, result size (both sequential + concurrent)
- Session lifecycle: turn start (session/model/provider/platform), compression (before/after)
- Credential pool: rotation events, exhaustion tracking

**Phase 3 — hermes logs CLI command**
- hermes logs / hermes logs -f / hermes logs errors / hermes logs gateway
- --level, --session, --since filters
- hermes logs list (file sizes + ages)

**Phase 4 — Gateway bug fix + noise reduction**
- fix: _async_flush_memories() called with wrong arg count — sessions never flushed
- Batched session expiry logs: 6 lines/cycle → 2 summary lines
- Added inbound message + response time logging

75 new tests, zero regressions on the full suite.
2026-04-06 00:08:20 -07:00
Teknium 89db3aeb2c fix(cron): add delivery guidance to cron prompt — stop send_message thrashing (#5444)
Cron agents were burning iterations trying to use send_message (which is
disabled via messaging toolset) because their prompts said things like
'send the report to Telegram'. The scheduler handles delivery
automatically via the deliver setting, but nothing told the agent that.

Add a delivery guidance hint to _build_job_prompt alongside the existing
[SILENT] hint: tells agents their final response is auto-delivered and
they should NOT use send_message.

Before: only [SILENT] suppression hint
After: delivery guidance ('do NOT use send_message') + [SILENT] hint
2026-04-05 23:58:45 -07:00
Teknium d6ef7fdf92 fix(cron): replace wall-clock timeout with inactivity-based timeout (#5440)
Port the gateway's inactivity-based timeout pattern (PR #5389) to the
cron scheduler. The agent can now run for hours if it's actively calling
tools or receiving stream tokens — only genuine inactivity (no activity
for HERMES_CRON_TIMEOUT seconds, default 600s) triggers a timeout.

This fixes the Sunday PR scouts (openclaw, nanoclaw, ironclaw) which
all hit the hard 600s wall-clock limit while actively working.

Changes:
- Replace flat future.result(timeout=N) with a polling loop that checks
  agent.get_activity_summary() every 5s (same pattern as gateway)
- Timeout error now includes diagnostic info: last activity description,
  idle duration, current tool, iteration count
- HERMES_CRON_TIMEOUT=0 means unlimited (no timeout)
- Move sys.path.insert before repo-level imports to fix
  ModuleNotFoundError for hermes_time on stale gateway processes
- Add time import needed by the polling loop
- Add 9 tests covering active/idle/unlimited/env-var/diagnostic scenarios
2026-04-05 23:49:42 -07:00
Teknium dc9c3cac87 chore: remove redundant local import of normalize_usage
Already imported at module level (line 94). The local import inside
_usage_summary_for_api_request_hook was unnecessary.
2026-04-05 23:31:29 -07:00
kshitijk4poor 38bcaa1e86 chore: remove langfuse doc, smoketest script, and installed-plugin test
Made-with: Cursor
2026-04-05 23:31:29 -07:00
kshitijk4poor f530ef1835 feat(plugins): pre_api_request/post_api_request with narrow payloads
- Rename per-LLM-call hooks from pre_llm_request/post_llm_request for clarity vs pre_llm_call
- Emit summary kwargs only (counts, usage dict from normalize_usage); keep env_var_enabled for HERMES_DUMP_REQUESTS
- Add is_truthy_value/env_var_enabled to utils; wire hermes_cli.plugins._env_enabled through it
- Update Langfuse local setup doc; add scripts/langfuse_smoketest.py and optional ~/.hermes plugin tests

Made-with: Cursor
2026-04-05 23:31:29 -07:00
kshitijk4poor 9e820dda37 Add request-scoped plugin lifecycle hooks 2026-04-05 23:31:29 -07:00
Teknium dce5f51c7c feat: config structure validation — detect malformed YAML at startup (#5426)
Add validate_config_structure() that catches common config.yaml mistakes:
- custom_providers as dict instead of list (missing '-' in YAML)
- fallback_model accidentally nested inside another section
- custom_providers entries missing required fields (name, base_url)
- Missing model section when custom_providers is configured
- Root-level keys that look like misplaced custom_providers fields

Surface these diagnostics at three levels:
1. Startup: print_config_warnings() runs at CLI and gateway module load,
   so users see issues before hitting cryptic errors
2. Error time: 'Unknown provider' errors in auth.py and model_switch.py
   now include config diagnostics with fix suggestions
3. Doctor: 'hermes doctor' shows a Config Structure section with all
   issues and fix hints

Also adds a warning log in runtime_provider.py when custom_providers
is a dict (previously returned None silently).

Motivated by a Discord user who had malformed custom_providers YAML
and got only 'Unknown Provider' with no guidance on what was wrong.

17 new tests covering all validation paths.
2026-04-05 23:31:20 -07:00
Teknium 9ca954a274 fix: mem0 API v2 compat, prefetch context fencing, secret redaction (#5423)
Consolidated salvage from PRs #5301 (qaqcvc), #5339 (lance0),
#5058 and #5098 (maymuneth).

Mem0 API v2 compatibility (#5301):
- All reads use filters={user_id: ...} instead of bare user_id= kwarg
- All writes use filters with user_id + agent_id for attribution
- Response unwrapping for v2 dict format {results: [...]}
- Split _read_filters() vs _write_filters() — reads are user-scoped
  only for cross-session recall, writes include agent_id
- Preserved 'hermes-user' default (no breaking change for existing users)
- Omitted run_id scoping from #5301 — cross-session memory is Mem0's
  core value, session-scoping reads would defeat that purpose

Memory prefetch context fencing (#5339):
- Wraps prefetched memory in <memory-context> fenced blocks with system
  note marking content as recalled context, NOT user input
- Sanitizes provider output to strip fence-escape sequences, preventing
  injection where memory content breaks out of the fence
- API-call-time only — never persisted to session history

Secret redaction (#5058, #5098):
- Added prefix patterns for Groq (gsk_), Matrix (syt_), RetainDB
  (retaindb_), Hindsight (hsk-), Mem0 (mem0_), ByteRover (brv_)
2026-04-05 22:43:33 -07:00
Teknium 786970925e fix(cli): add missing subprocess.run() timeouts in gateway CLI (#5424)
All 35 subprocess.run() calls in hermes_cli/gateway.py lacked timeout
parameters. If systemctl, launchctl, loginctl, wmic, or ps blocks,
hermes gateway start/stop/restart/status/install/uninstall hangs
indefinitely with no feedback.

Timeouts tiered by operation type:
- 10s: instant queries (is-active, status, list, ps, tail, journalctl)
- 30s: fast lifecycle (daemon-reload, enable, start, bootstrap, kickstart)
- 90s: graceful shutdown (stop, restart, bootout, kickstart -k) — exceeds
  our TimeoutStopSec=60 to avoid premature timeout during shutdown

Special handling: _is_service_running() and launchd_status() catch
TimeoutExpired and treat it as not-running/not-loaded, consistent with
how non-zero return codes are already handled.

Inspired by PR #3732 (dlkakbs) and issue #4057 (SHL0MS).
Reimplemented on current main which has significantly changed launchctl
handling (bootout/bootstrap/kickstart vs legacy load/unload/start/stop).
2026-04-05 22:41:42 -07:00
Teknium ab086a320b chore: remove qwen-3.6 free from nous portal model list 2026-04-05 22:40:34 -07:00
Teknium aa56df090f fix: allow env var overrides for Nous portal/inference URLs (#5419)
The _login_nous() call site was pre-filling portal_base_url,
inference_base_url, client_id, and scope with pconfig defaults before
passing them to _nous_device_code_login(). Since pconfig defaults are
always truthy, the env var checks inside the function (HERMES_PORTAL_BASE_URL,
NOUS_PORTAL_BASE_URL, NOUS_INFERENCE_BASE_URL) could never take effect.

Fix: pass None from the call site when no CLI flag is provided, letting
the function's own priority chain handle defaults correctly:
explicit CLI flag > env var > pconfig default.

Addresses the issue reported in PR #5397 by jquesnelle.
2026-04-05 22:33:24 -07:00
SHL0MS 033e971140 Merge pull request #5421 from NousResearch/fix/research-paper-writing-gaps
feat(research-paper-writing): fill coverage gaps, integrate AI-Scientist & GPT-Researcher patterns
2026-04-06 01:13:49 -04:00
SHL0MS 95a044a2e0 feat(research-paper-writing): fill coverage gaps and integrate patterns from AI-Scientist, GPT-Researcher
Fix duplicate step numbers (5.3, 7.3) and missing 7.5. Add coverage for
human evaluation, theory/survey/benchmark/position papers, ethics/broader
impact, arXiv strategy, code packaging, negative results, workshop papers,
multi-author coordination, compute budgeting, and post-acceptance
deliverables. Integrate ensemble reviewing with meta-reviewer and negative
bias, pre-compilation validation pipeline, experiment journal with tree
structure, breadth/depth literature search, context management for large
projects, two-pass refinement, VLM visual review, and claim verification.

New references: human-evaluation.md, paper-types.md.
2026-04-06 01:12:32 -04:00
Teknium 38d8446011 feat: implement MCP OAuth 2.1 PKCE client support (#5420)
Implement tools/mcp_oauth.py — the OAuth adapter that mcp_tool.py's
existing auth: oauth hook has been waiting for.

Components:
- HermesTokenStorage: persists tokens + client registration to
  HERMES_HOME/mcp-tokens/<server>.json with 0o600 permissions
- Callback handler factory: per-flow isolated HTTP handlers (safe for
  concurrent OAuth flows across multiple MCP servers)
- OAuthClientProvider integration: wraps the MCP SDK's httpx.Auth
  subclass which handles discovery, DCR, PKCE, token exchange,
  refresh, and step-up auth (403 insufficient_scope) automatically
- Non-interactive detection: warns when gateway/cron environments
  try to OAuth without cached tokens
- Pre-registered client support: injects client_id/secret from config
  for servers that don't support Dynamic Client Registration (e.g. Slack)
- Path traversal protection on server names
- remove_oauth_tokens() for cleanup

Config format:
  mcp_servers:
    sentry:
      url: 'https://mcp.sentry.dev/mcp'
      auth: oauth
      oauth:                          # all optional
        client_id: '...'              # skip DCR
        client_secret: '...'          # confidential client
        scope: 'read write'           # server-provided by default

Also passes oauth config dict through from mcp_tool.py (was passing
only server_name and url before).

E2E verified: full OAuth flow (401 → discovery → DCR → authorize →
token exchange → authenticated request → tokens persisted) against
local test servers. 23 unit tests + 186 MCP suite tests pass.
2026-04-05 22:08:00 -07:00
emozilla 3962bc84b7 show cache pricing as well (if supported) 2026-04-05 22:02:21 -07:00
emozilla 0365f6202c feat: show model pricing for OpenRouter and Nous Portal providers
Display live per-million-token pricing from /v1/models when listing
models for OpenRouter or Nous Portal. Prices are shown in a
column-aligned table with decimal points vertically aligned for
easy comparison.

Pricing appears in three places:
- /provider slash command (table with In/Out headers)
- hermes model picker (aligned columns in both TerminalMenu and
  numbered fallback)

Implementation:
- Add fetch_models_with_pricing() in models.py with per-base_url
  module-level cache (one network call per endpoint per session)
- Add _format_price_per_mtok() with fixed 2-decimal formatting
- Add format_model_pricing_table() for terminal table display
- Add get_pricing_for_provider() convenience wrapper
- Update _prompt_model_selection() to accept optional pricing dict
- Wire pricing through _model_flow_openrouter/nous in main.py
- Update test mocks for new pricing parameter
2026-04-05 22:02:21 -07:00
Teknium 0efe7dace7 feat: add GPT/Codex execution discipline guidance for tool persistence (#5414)
Adds OPENAI_MODEL_EXECUTION_GUIDANCE — XML-tagged behavioral guidance
injected for GPT and Codex models alongside the existing tool-use
enforcement. Targets four specific failure modes:

- <tool_persistence>: retry on empty/partial results instead of giving up
- <prerequisite_checks>: do discovery/lookup before jumping to final action
- <verification>: check correctness/grounding/formatting before finalizing
- <missing_context>: use lookup tools instead of hallucinating

Follows the same injection pattern as GOOGLE_MODEL_OPERATIONAL_GUIDANCE
for Gemini/Gemma models. Inspired by OpenClaw PR #38953 and OpenAI's
GPT-5.4 prompting guide patterns.
2026-04-05 21:51:07 -07:00
SHL0MS 4e196a5428 Merge pull request #5411 from SHL0MS/fix/manim-monospace-fonts
fix(manim-video): recommend monospace fonts — proportional fonts have broken kerning
2026-04-06 00:36:19 -04:00
SHL0MS b26e7fd43a fix(manim-video): recommend monospace fonts — proportional fonts have broken kerning in Pango
Manim's Pango text renderer produces broken kerning with proportional
fonts (Helvetica, Inter, SF Pro, Arial) at all sizes and resolutions.
Characters overlap and spacing is inconsistent. This is a fundamental
Pango limitation.

Changes:
- Recommend Menlo (monospace) as the default font for ALL text
- Proportional fonts only acceptable for large titles (>=48, short strings)
- Set minimum font_size=18 for readability
- Update all code examples to use MONO='Menlo' pattern
- Remove Inter/Helvetica/SF Pro from recommendations
2026-04-06 00:35:43 -04:00
SHL0MS 084cd1f840 Merge pull request #5408 from SHL0MS/feat/manim-skill-improvements
docs(manim-video): expand references with Manim CE API coverage and 3b1b production patterns
2026-04-06 00:09:25 -04:00
SHL0MS 447ec076a4 docs(manim-video): expand references with comprehensive Manim CE and 3b1b patterns
Adds 601 lines across 6 reference files, sourced from deep review of:
- Manim CE v0.20.1 full reference manual
- 3b1b/manim example_scenes.py and source modules
- 3b1b/videos production CLAUDE.md and workflow patterns
- Manim CE thematic guides (voiceover, text, configuration)

animations.md: always_redraw, TracedPath, FadeTransform,
  TransformFromCopy, ApplyMatrix, squish_rate_func,
  ShowIncreasingSubsets, ShowPassingFlash, expanded rate functions

mobjects.md: SVGMobject, ImageMobject, Variable, BulletedList,
  DashedLine, Angle/RightAngle, boolean ops, LabeledArrow,
  t2c/t2f/t2s/t2w per-substring styling, backstroke for readability,
  apply_complex_function with prepare_for_nonlinear_transform

equations.md: substrings_to_isolate, multi-line equations,
  TransformMatchingTex with matched_keys and key_map,
  set_color_by_tex

graphs-and-data.md: Graph/DiGraph with layout algorithms,
  ArrowVectorField/StreamLines, ComplexPlane/PolarPlane

camera-and-3d.md: ZoomedScene with inset zoom,
  LinearTransformationScene for 3b1b-style linear algebra

rendering.md: manim.cfg project config, self.next_section()
  chapter markers, manim-voiceover plugin with ElevenLabs/GTTS
  integration and bookmark-based audio sync
2026-04-06 00:08:17 -04:00
Teknium 89c812d1d2 feat: shared thread sessions by default — multi-user thread support (#5391)
Threads (Telegram forum topics, Discord threads, Slack threads) now default
to shared sessions where all participants see the same conversation. This is
the expected UX for threaded conversations where multiple users @mention the
bot and interact collaboratively.

Changes:
- build_session_key(): when thread_id is present, user_id is no longer
  appended to the session key (threads are shared by default)
- New config: thread_sessions_per_user (default: false) — opt-in to restore
  per-user isolation in threads if needed
- Sender attribution: messages in shared threads are prefixed with
  [sender name] so the agent can tell participants apart
- System prompt: shared threads show 'Multi-user thread' note instead of
  a per-turn User line (avoids busting prompt cache)
- Wired through all callers: gateway/run.py, base.py, telegram.py, feishu.py
- Regular group messages (no thread) remain per-user isolated (unchanged)
- DM threads are unaffected (they have their own keying logic)

Closes community request from demontut_ re: thread-based shared sessions.
2026-04-05 19:46:58 -07:00
Teknium 43d468cea8 docs: comprehensive documentation audit — fix stale info, expand thin pages, add depth (#5393)
Major changes across 20 documentation pages:

Staleness fixes:
- Fix FAQ: wrong import path (hermes.agent → run_agent)
- Fix FAQ: stale Gemini 2.0 model → Gemini 3 Flash
- Fix integrations/index: missing MiniMax TTS provider
- Fix integrations/index: web_crawl is not a registered tool
- Fix sessions: add all 19 session sources (was only 5)
- Fix cron: add all 18 delivery targets (was only telegram/discord)
- Fix webhooks: add all delivery targets
- Fix overview: add missing MCP, memory providers, credential pools
- Fix all line-number references → use function name searches instead
- Update file size estimates (run_agent ~9200, gateway ~7200, cli ~8500)

Expanded thin pages (< 150 lines → substantial depth):
- honcho.md: 43 → 108 lines — added feature comparison, tools, config, CLI
- overview.md: 49 → 55 lines — added MCP, memory providers, credential pools
- toolsets-reference.md: 57 → 175 lines — added explanations, config examples,
  custom toolsets, wildcards, platform differences table
- optional-skills-catalog.md: 74 → 153 lines — added 25+ missing skills across
  communication, devops, mlops (18!), productivity, research categories
- integrations/index.md: 82 → 115 lines — added messaging, HA, plugins sections
- cron-internals.md: 90 → 195 lines — added job JSON example, lifecycle states,
  tick cycle, delivery targets, script-backed jobs, CLI interface
- gateway-internals.md: 111 → 250 lines — added architecture diagram, message
  flow, two-level guard, platform adapters, token locks, process management
- agent-loop.md: 112 → 235 lines — added entry points, API mode resolution,
  turn lifecycle detail, message alternation rules, tool execution flow,
  callback table, budget tracking, compression details
- architecture.md: 152 → 295 lines — added system overview diagram, data flow
  diagrams, design principles table, dependency chain

Other depth additions:
- context-references.md: added platform availability, compression interaction,
  common patterns sections
- slash-commands.md: added quick commands config example, alias resolution
- image-generation.md: added platform delivery table
- tools-reference.md: added tool counts, MCP tools note
- index.md: updated platform count (5 → 14+), tool count (40+ → 47)
2026-04-05 19:45:50 -07:00
Teknium fec58ad99e fix(gateway): replace wall-clock agent timeout with inactivity-based timeout (#5389)
The gateway previously used a hard wall-clock asyncio.wait_for timeout
that killed agents after a fixed duration regardless of activity. This
punished legitimate long-running tasks (subagent delegation, reasoning
models, multi-step research).

Now uses an inactivity-based polling loop that checks the agent's
built-in activity tracker (get_activity_summary) every 5 seconds. The
agent can run indefinitely as long as it's actively calling tools or
receiving API responses. Only fires when the agent has been completely
idle for the configured duration.

Changes:
- Replace asyncio.wait_for with asyncio.wait poll loop checking
  agent idle time via get_activity_summary()
- Add agent.gateway_timeout config.yaml key (default 1800s, 0=unlimited)
- Update stale session eviction to use agent idle time instead of
  pure wall-clock (prevents evicting active long-running tasks)
- Preserve all existing diagnostic logging and user-facing context

Inspired by PR #4864 (Mibayy) and issue #4815 (BongSuCHOI).
Reimplemented on current main using existing _touch_activity()
infrastructure rather than a parallel tracker.
2026-04-05 19:38:21 -07:00
Teknium 8972eb05fd docs: add comprehensive Discord configuration reference (#5386)
Add full Configuration Reference section to Discord docs covering all
env vars (10 total) and config.yaml options with types, defaults, and
detailed explanations. Previously undocumented: DISCORD_AUTO_THREAD,
DISCORD_ALLOW_BOTS, DISCORD_REACTIONS, discord.auto_thread,
discord.reactions, display.tool_progress, display.tool_progress_command.
Cleaned up manual setup flow to show only required vars.
2026-04-05 19:17:24 -07:00
Teknium fc15f56fc4 feat: warn users when loading non-agentic Hermes LLM models (#5378)
Nous Research Hermes 3 & 4 models lack tool-calling capabilities and
are not suitable for agent workflows. Add a warning that fires in two
places:

- /model switch (CLI + gateway) via model_switch.py warning_message
- CLI session startup banner when the configured model contains 'hermes'

Both paths suggest switching to an agentic model (Claude, GPT, Gemini,
DeepSeek, etc.).
2026-04-05 18:41:03 -07:00
Dusk1e e9ddfee4fd fix(plugins): reject plugin names that resolve to the plugins root
Reject "." as a plugin name — it resolves to the plugins directory
itself, which in force-install flows causes shutil.rmtree to wipe the
entire plugins tree.

- reject "." early with a clear error message
- explicit check for target == plugins_resolved (raise instead of allow)
- switch boundary check from string-prefix to Path.relative_to()
- add regression tests for sanitizer + install flow

Co-authored-by: Dusk1e <yusufalweshdemir@gmail.com>
2026-04-05 18:40:45 -07:00
Teknium 2563493466 fix: improve timeout debug logging and user-facing diagnostics (#5370)
Agent activity tracking:
- Add _last_activity_ts, _last_activity_desc, _current_tool to AIAgent
- Touch activity on: API call start/complete, tool start/complete,
  first stream chunk, streaming request start
- Public get_activity_summary() method for external consumers

Gateway timeout diagnostics:
- Timeout message now includes what the agent was doing when killed:
  actively working vs stuck on a tool vs waiting on API response
- Includes iteration count, last activity description, seconds since
  last activity — users can distinguish legitimate long tasks from
  genuine hangs
- 'Still working' notifications now show iteration count and current
  tool instead of just elapsed time
- Stale lock eviction logs include agent activity state for debugging

Stream stale timeout:
- _emit_status when stale stream is detected (was log-only) — gateway
  users now see 'No response from provider for Ns' with model and
  context size
- Improved logger.warning with model name and estimated context size

Error path notifications (gateway-visible via _emit_status):
- Context compression attempts now use _emit_status (was _vprint only)
- Non-retryable client errors emit summary before aborting
- Max retry exhaustion emits error summary (was _vprint only)
- Rate limit exhaustion emits specific rate-limit message

These were all CLI-visible but silent to gateway users, which is why
people on Telegram/Discord saw generic 'request failed' messages
without explanation.
2026-04-05 18:33:33 -07:00
SHL0MS 1572956fdc Merge pull request #4930 from SHL0MS/feat/manim-video-skill-v2
feat(skills): add manim-video skill for mathematical and technical animations
2026-04-05 16:10:30 -07:00
SHL0MS 9d885b266c feat(skills): add manim-video skill for mathematical and technical animations
Production pipeline for creating 3Blue1Brown-style animated videos
using Manim Community Edition. The agent handles the full workflow:
creative planning, Python code generation, rendering, scene stitching,
audio muxing, and iterative refinement.

Modes: concept explainers, equation derivations, algorithm
visualizations, data stories, architecture diagrams, paper explainers,
3D visualizations.

9 reference files, setup verification script, README.
All API references verified against ManimCommunity/manim source.
2026-04-05 19:09:37 -04:00
donrhmexe 7409715947 fix: link subagent sessions to parent and hide from session list
Subagent sessions spawned by delegate_task were created with
parent_session_id=NULL and source=cli, making them indistinguishable
from user sessions in hermes sessions list and /resume.

Changes:
- delegate_tool.py: pass parent_agent.session_id to child agent
- run_agent.py: accept parent_session_id param, pass to create_session
- hermes_state.py list_sessions_rich: filter parent_session_id IS NULL
  by default (opt-in include_children=True for callers that need them)
- hermes_state.py delete_session: delete child sessions first (FK)
- hermes_state.py prune_sessions: delete children before parents (FK)

session_search already handles parent_session_id correctly — child
sessions are filtered from recent list and resolved to parent root
in full-text search results.

Fixes #5122
2026-04-05 12:48:50 -07:00
Teknium efa03fc07d docs: update honcho CLI reference + document plugin CLI registration (#5308)
Post PR #5295 docs audit — 4 fixes:

1. cli-commands.md: Update hermes honcho subcommand table with 4
   missing commands (peers, enable, disable, sync), --target-profile
   flag, --all on status, correct mode values (hybrid/context/tools
   not hybrid/honcho/local), and note that setup redirects to
   hermes memory setup.

2. build-a-hermes-plugin.md: Replace 'ctx.register_command() —
   planned but not yet implemented' with the actual implemented
   ctx.register_cli_command() API. Add full Register CLI commands
   section with code example.

3. memory-provider-plugin.md: Add 'Adding CLI Commands' section
   documenting the register_cli(subparser) convention for memory
   provider plugins, active-provider gating, and directory structure.

4. plugins.md: Add CLI command registration to the capabilities table.
2026-04-05 12:48:20 -07:00
Teknium 4494fba140 feat: OSV malware check for MCP extension packages (#5305)
Before launching an MCP server via npx/uvx, queries the OSV (Open Source
Vulnerabilities) API to check if the package has known malware advisories
(MAL-* IDs). Regular CVEs are ignored — only confirmed malware is blocked.

- Free, public API (Google-maintained), ~300ms per query
- Runs once per MCP server launch, inside _run_stdio() before subprocess spawn
- Parallel with other MCP servers (asyncio.gather already in place)
- Fail-open: network errors, timeouts, unrecognized commands → allow
- Parses npm (scoped @scope/pkg@version) and PyPI (name[extras]==version)

Inspired by Block/goose extension malware check.
2026-04-05 12:46:07 -07:00
Teknium b63fb03f3f feat(browser): add JS evaluation via browser_console expression parameter (#5303)
Add optional 'expression' parameter to browser_console that evaluates
JavaScript in the page context (like DevTools console). Returns structured
results with auto-JSON parsing.

No new tool — extends the existing browser_console schema with ~20 tokens
of overhead instead of adding a 12th browser tool.

Both backends supported:
- Browserbase: uses agent-browser 'eval' command via CDP
- Camofox: uses /tabs/{tab_id}/eval endpoint with graceful degradation

E2E verified: string eval, number eval, structured JSON, DOM manipulation,
error handling, and original console-output mode all working.
2026-04-05 12:42:52 -07:00
Teknium 8d5226753f fix: add missing ButtonStyle.grey to discord mock for test compatibility 2026-04-05 12:42:47 -07:00
Abhey 66d0fa1778 fix: avoid unnecessary Discord members intent on startup
Only request the privileged members intent when DISCORD_ALLOWED_USERS includes non-numeric entries that need username resolution. Also release the Discord token lock when startup fails so retries and restarts are not blocked by a stale lock.\n\nAdds regression tests for conditional intents and startup lock cleanup.
2026-04-05 12:42:47 -07:00
Teknium 583d9f9597 fix(honcho): migration guard for observation mode default change
Existing honcho.json configs without an explicit observationMode now
default to 'unified' (the old default) instead of being silently
switched to 'directional'. New installations get 'directional' as
the new default.

Detection: _explicitly_configured (host block exists or enabled=true)
signals an existing config. When true and no observationMode is set
anywhere in the config chain, falls back to 'unified'. When false
(fresh install), uses 'directional'.

Users who explicitly set observationMode or granular observation
booleans are unaffected — explicit config always wins.

5 new tests covering all migration paths.
2026-04-05 12:34:11 -07:00
Teknium 0f813c422c fix(plugins): only register CLI commands for the active memory provider
discover_plugin_cli_commands() now reads memory.provider from config.yaml
and only loads CLI registration for the active provider. If no memory
provider is set, no plugin CLI commands appear in the CLI.

Only one memory provider can be active at a time — at most one set of
plugin CLI commands is registered. Users who haven't configured honcho
(or any memory provider) won't see 'hermes honcho' in their help output.

Adds test for inactive provider returning empty results.
2026-04-05 12:34:11 -07:00
Teknium b074b0b13a test: add plugin CLI registration tests
11 tests covering:
- PluginContext.register_cli_command() storage and overwrite
- get_plugin_cli_commands() return semantics
- Memory plugin discover_plugin_cli_commands() with register_cli convention
- Skipping plugins without register_cli or cli.py
- Honcho register_cli() subcommand tree structure
- Mode choices updated to recall modes (hybrid/context/tools)
- _ProviderCollector.register_cli_command no-op safety
2026-04-05 12:34:11 -07:00
Teknium dd8a42bf7d feat(plugins): plugin CLI registration system — decouple plugin commands from core
Add ctx.register_cli_command() to PluginContext for general plugins and
discover_plugin_cli_commands() to memory plugin system. Plugins that
provide a register_cli(subparser) function in their cli.py are
automatically discovered during argparse setup and wired into the CLI.

- Remove 95-line hardcoded honcho argparse block from main.py
- Move honcho subcommand tree into plugins/memory/honcho/cli.py
  via register_cli() convention
- hermes honcho setup now redirects to hermes memory setup (unified path)
- hermes honcho (no subcommand) shows status instead of running setup
- Future plugins can register CLI commands without touching core files
- PluginManager stores CLI registrations in _cli_commands dict
- Memory plugin discovery scans cli.py for register_cli at argparse time

main.py: -102 lines of hardcoded plugin routing
2026-04-05 12:34:11 -07:00
erosika c02c3dc723 fix(honcho): plugin drift overhaul -- observation config, chunking, setup wizard, docs, dead code cleanup
Salvaged from PR #5045 by erosika.

- Replace memoryMode/peer_memory_modes with granular per-peer observation config
- Add message chunking for Honcho API limits (25k chars default)
- Add dialectic input guard (10k chars default)
- Add dialecticDynamic toggle for reasoning level auto-bump
- Rewrite setup wizard with cloud/local deployment picker
- Switch peer card/profile/search from session.context() to direct peer APIs
- Add server-side observation sync via get_peer_configuration()
- Fix base_url/baseUrl config mismatch for self-hosted setups
- Fix local auth leak (cloud API keys no longer sent to local instances)
- Remove dead code: memoryMode, peer_memory_modes, linkedHosts, suppress flags, SOUL.md aiPeer sync
- Add post_setup hook to memory_setup.py for provider-specific setup wizards
- Comprehensive README rewrite with full config reference
- New optional skill: autonomous-ai-agents/honcho
- Expanded memory-providers.md with multi-profile docs
- 9 new tests (chunking, dialectic guard, peer lookups), 14 dead tests removed
- Fix 2 pre-existing TestResolveConfigPath filesystem isolation failures
2026-04-05 12:34:11 -07:00
Teknium 12724e6295 feat: progressive subdirectory hint discovery (#5291)
As the agent navigates into subdirectories via tool calls (read_file,
terminal, search_files, etc.), automatically discover and load project
context files (AGENTS.md, CLAUDE.md, .cursorrules) from those directories.

Previously, context files were only loaded from the CWD at session start.
If the agent moved into backend/, frontend/, or any subdirectory with its
own AGENTS.md, those instructions were never seen.

Now, SubdirectoryHintTracker watches tool call arguments for file paths
and shell commands, resolves directories, and loads hint files on first
access. Discovered hints are appended to the tool result so the model
gets relevant context at the moment it starts working in a new area —
without modifying the system prompt (preserving prompt caching).

Features:
- Extracts paths from tool args (path, workdir) and shell commands
- Loads AGENTS.md, CLAUDE.md, .cursorrules (first match per directory)
- Deduplicates — each directory loaded at most once per session
- Ignores paths outside the working directory
- Truncates large hint files at 8K chars
- Works on both sequential and concurrent tool execution paths

Inspired by Block/goose SubdirectoryHintTracker.
2026-04-05 12:33:47 -07:00
Teknium 567bc79948 fix: clean up cron platform allowlist — add homeassistant, fix import, improve placement
Follow-up for cherry-picked #5118 commits:
- Remove duplicate 'import subprocess'
- Move _KNOWN_DELIVERY_PLATFORMS to module-level (after imports)
- Add 'homeassistant' to allowlist (existing platform missing from original PR)
- Remove trailing whitespace
2026-04-05 12:31:27 -07:00
Maymun 71a4582bf8 fix(security): hoist platform allowlist to module scope as frozenset 2026-04-05 12:31:27 -07:00
Maymun 1ebc932417 fix(security): validate cron deliver platform name to prevent env var enumeration 2026-04-05 12:31:27 -07:00
Xowiek ef3bd3b276 security(approval): fix privilege escalation in gateway once-approval logic 2026-04-05 12:31:27 -07:00
MichaelWDanko c6793d6fc3 fix(gateway): wrap cron helpers with staticmethod to prevent self-binding
Plain functions imported as class attributes in APIServerAdapter get
auto-bound as methods via Python's descriptor protocol.  Every
self._cron_*() call injected self as the first positional argument,
causing TypeError on all 8 cron API endpoints at runtime.

Wrap each import with staticmethod() so self._cron_*() calls dispatch
correctly without modifying any call sites.

Co-authored-by: teknium <teknium@nousresearch.com>
2026-04-05 12:31:10 -07:00
Mibayy cc2b56b26a feat(api): structured run events via /v1/runs SSE endpoint
Add POST /v1/runs to start async agent runs and GET /v1/runs/{run_id}/events
for SSE streaming of typed lifecycle events (tool.started, tool.completed,
message.delta, reasoning.available, run.completed, run.failed).

Changes the internal tool_progress_callback signature from positional
(tool_name, preview, args) to event-type-first
(event_type, tool_name, preview, args, **kwargs). Existing consumers
filter on event_type and remain backward-compatible.

Adds concurrency limit (_MAX_CONCURRENT_RUNS=10) and orphaned run sweep.

Fixes logic inversion in cli.py _on_tool_progress where the original PR
would have displayed internal tools instead of non-internal ones.

Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 12:05:13 -07:00
Mibayy e167ad8f61 feat(delegate): add acp_command/acp_args override to delegate_task
Allow delegate_task to specify custom ACP transport per-task, so a parent
running via CLI/Discord/Telegram can spawn child agents over ACP
(e.g. claude --acp --stdio). Follows the existing override_provider pattern.
Supports per-task granularity in batch mode.

Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 12:05:13 -07:00
NexVeridian c71b1d197f fix(acp): advertise slash commands via ACP protocol
Send AvailableCommandsUpdate on session create/load/resume/fork so ACP
clients (Zed, etc.) can discover /help, /model, /tools, /compact, etc.
Also rewrites /compact to use agent._compress_context() properly with
token estimation and session DB isolation.

Co-authored-by: NexVeridian <NexVeridian@users.noreply.github.com>
2026-04-05 12:05:13 -07:00
Git-on-my-level fcdd5447e2 fix: keep ACP stdout protocol-clean
Route AIAgent print output to stderr via _print_fn for ACP stdio sessions.
Gate quiet-mode spinner startup on _should_start_quiet_spinner() so JSON-RPC
on stdout isn't corrupted. Child agents inherit the redirect.

Co-authored-by: Git-on-my-level <Git-on-my-level@users.noreply.github.com>
2026-04-05 12:05:13 -07:00
Teknium 914a7db448 fix(acp): rename AuthMethod to AuthMethodAgent for agent-client-protocol 0.9.0
Straight rename to match the 0.9.0 API where AuthMethod was split into
AuthMethodAgent, AuthMethodEnvVar, AuthMethodTerminal. Bump pin to >=0.9.0,<1.0.

Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
2026-04-05 12:05:13 -07:00
Teknium 6ee90a7cf6 fix: hermes auth remove now clears env-seeded credentials permanently (#5285)
Removing an env-seeded credential (e.g. from OPENROUTER_API_KEY) via
'hermes auth' previously had no lasting effect -- the entry was deleted
from auth.json but load_pool() re-created it on the next call because
the env var was still set.

Now auth_remove_command detects env-sourced entries (source starts with
'env:') and calls the new remove_env_value() to strip the var from both
.env and os.environ, preventing re-seeding.

Changes:
- hermes_cli/config.py: add remove_env_value() -- atomically removes a
  line from .env and pops from os.environ
- hermes_cli/auth_commands.py: auth_remove_command clears env var when
  removing an env-seeded pool entry
- 8 new tests covering remove_env_value and the full zombie-credential
  lifecycle (remove -> reload -> stays gone)
2026-04-05 12:00:53 -07:00
Teknium 0c95e91059 fix: follow-up fixes for salvaged PRs
- Fix GatewayApp → GatewayRunner import in api_server.py (PR #4976)
- Update launchd test assertions for new bootstrap/bootout/kickstart commands (PR #4892)
- Add nonlocal message declaration in run_sync() to fix UnboundLocalError (pre-existing scoping bug)
2026-04-05 11:59:28 -07:00
analista 6a6ae9a5c3 fix(gateway): correct misleading log text for unknown /commands
The warning said 'forwarding as plain text' but the code returns a
user-facing error reply instead of forwarding. Describe what actually
happens.
2026-04-05 11:59:28 -07:00
analista e8053e8b93 fix(gateway): surface unknown /commands instead of leaking them to the LLM
Previously, typing a /command that isn't a built-in, plugin, or skill
would silently fall through to the LLM as plain text. The model often
interprets it as a loose instruction and invents unrelated tool calls —
e.g. a stray /claude_code slipped through and the model fabricated a
delegate_task invocation that got stuck in an OAuth loop.

Now we check GATEWAY_KNOWN_COMMANDS after the skill / plugin /
unavailable-skill lookups and return an actionable message pointing the
user at /commands. The user gets feedback, and the agent doesn't waste
a round-trip guessing what /foo-bar was supposed to mean.
2026-04-05 11:59:28 -07:00
analista 4a75aec433 fix(gateway): resolve Telegram's underscored /commands to skill/plugin keys
Telegram's Bot API disallows hyphens in command names, so
_build_telegram_menu registers /claude-code as /claude_code. When the
user taps it from autocomplete, the gateway dispatch did a direct
lookup against skill_cmds (keyed on the hyphenated form) and missed,
silently falling through to the LLM as plain text. The model would
then typically call delegate_task, spawning a Hermes subagent instead
of invoking the intended skill.

Normalize underscores to hyphens in skill and plugin command lookup,
matching the existing pattern in _check_unavailable_skill.
2026-04-05 11:59:28 -07:00
Damian P afccbf253c fix: resolve listed messaging targets consistently 2026-04-05 11:59:28 -07:00
kshitijk4poor 1d2e34c7eb Prevent Telegram polling handoffs and flood-control send failures
Telegram polling can inherit a stale webhook registration when a deployment
switches transport modes, which leaves getUpdates idle even though the gateway
starts cleanly. Outbound send also treats Telegram retry_after responses as
terminal errors, so brief flood control can drop tool progress and replies.

Constraint: Keep the PR narrowly scoped to upstream/main Telegram adapter behavior
Rejected: Port OpenClaw's broader polling supervisor and offset persistence | too broad for an isolated fix PR
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Polling mode should clear webhook state before starting getUpdates, and send-path retry logic must distinguish flood control from timeouts
Tested: uv run --extra dev pytest tests/gateway/test_telegram_* -q
Not-tested: Live Telegram webhook-to-polling migration and real Bot API 429 behavior
2026-04-05 11:59:28 -07:00
Trevin Chow 74ff62f5ac fix(gateway): use kickstart -k for atomic launchd restart
Replace the two-step stop/start restart with a single
launchctl kickstart -k call. When the gateway triggers a
restart from inside its own process tree, the old stop
command kills the shell before the start half is reached.
kickstart -k lets launchd handle the kill+restart atomically.
2026-04-05 11:59:28 -07:00
Trevin Chow aab74b582c fix(gateway): replace deprecated launchctl start/stop with kickstart/kill
launchctl load/unload/start/stop are deprecated on macOS since 10.10
and fail silently on modern versions. This replaces them with the
current equivalents:

- load -> bootstrap gui/<uid> <plist>
- unload -> bootout gui/<uid>/<label>
- start -> kickstart gui/<uid>/<label>
- stop -> kill SIGTERM gui/<uid>/<label>

Adds _launchd_domain() helper returning the gui/<uid> target domain.
Updates test assertions to match the new command signatures.

Fixes #4820
2026-04-05 11:59:28 -07:00
bg-l2norm abf1be564b fix(deps): include telegram webhook extra in messaging installs (#4915) 2026-04-05 11:59:28 -07:00
teyrebaz33 6df0f07ff3 fix: /status command bypasses active-session guard during agent run (#5046)
When an agent was actively processing a message, /status sent via Telegram
(or any gateway) was queued as a pending interrupt instead of being dispatched
immediately. The base platform adapter's handle_message() only had special-case
bypass logic for /approve and /deny, so /status fell through to the default
interrupt path and was never processed as a system command.

Apply the same bypass pattern used by /approve//deny: detect cmd == 'status'
inside the active-session guard, dispatch directly to the message handler, and
send the response without touching session lifecycle or interrupt state.

Adds a regression test that verifies /status is dispatched and responded to
immediately even when _active_sessions contains an entry for the session.
2026-04-05 11:59:28 -07:00
nibzard 4df2fca2f0 fix(gateway): cap memory flush retries at 3 to prevent infinite loop
The _session_expiry_watcher retried failed memory flushes forever
because exceptions were caught at debug level without setting
memory_flushed=True. Expired sessions with transient failures
(rate limits, network errors) would retry every 5 minutes
indefinitely, burning API quota and blocking gateway message
processing via 429 rate limit cascades.

Observed case: a March 19 session retried 28+ times over ~17 days,
causing repeated 429 errors that made Telegram unresponsive.

Add a per-session failure counter (_flush_failures) that gives up
after 3 consecutive attempts and marks the session as flushed to
break the loop.
2026-04-05 11:59:28 -07:00
Saurabh 507b63f86b fix(api-server): pass fallback_model to AIAgent (#4954)
The API server platform never passed fallback_model to AIAgent(),
so the fallback provider chain was always empty for requests through
the OpenAI-compatible endpoint. Load it via GatewayApp._load_fallback_model()
to match the behavior of Telegram/Discord/Slack platforms.
2026-04-05 11:59:28 -07:00
memosr 7f853ba7b6 fix: use logger.exception to preserve traceback in logs and drop unused import 2026-04-05 11:59:28 -07:00
memosr 5ff514ec79 fix(security): remove full traceback from cron error output to prevent info leakage 2026-04-05 11:59:28 -07:00
Teknium daa4a5acdd feat: add docs links to setup wizard sections (#5283)
Each setup step now shows a link to the relevant docs page:
- Model & Provider → integrations/providers
- Terminal Backend → developer-guide/environments
- Agent Settings → user-guide/configuration
- Messaging Platforms → user-guide/messaging (overview)
- Telegram, Discord, Matrix, Mattermost, WhatsApp → per-platform guides
- Tools → user-guide/features/tools

Existing Slack and Webhook URLs migrated to shared _DOCS_BASE constant.
2026-04-05 11:46:13 -07:00
Teknium 54cb311f40 fix: suppress false 'Unknown toolsets' warning for MCP server names (#5279)
MCP server names (e.g. annas, libgen) are added to enabled_toolsets by
_get_platform_tools() but aren't registered in TOOLSETS until later when
_sync_mcp_toolsets() runs during tool discovery. The validation in
HermesCLI.__init__() fires before that, producing a false warning.

Fix: exclude configured MCP server names from the validation check.
CLI_CONFIG is already available at the call site, so no new imports needed.

Closes #5267 (alternative fix)
2026-04-05 11:44:40 -07:00
Teknium a0a1b86c2e fix: accept reasoning-only responses without retries — set content to "(empty)" (#5278)
* feat: coerce tool call arguments to match JSON Schema types

LLMs frequently return numbers as strings ("42" instead of 42) and
booleans as strings ("true" instead of true). This causes silent
failures with MCP tools and any tool with strictly-typed parameters.

Added coerce_tool_args() in model_tools.py that runs before every tool
dispatch. For each argument, it checks the tool registry schema and
attempts safe coercion:
  - "42" → 42 when schema says "type": "integer"
  - "3.14" → 3.14 when schema says "type": "number"
  - "true"/"false" → True/False when schema says "type": "boolean"
  - Union types tried in order
  - Original values preserved when coercion fails or is not applicable

Inspired by Block/goose tool argument coercion system.

* fix: accept reasoning-only responses without retries — set content to "(empty)"

Previously, when a model returned reasoning/thinking but no visible
content, we entered a 120-line retry/classify/compress/salvage cascade
that wasted 3+ API calls trying to "fix" the response. The model was
done thinking — retrying with the same input just burned money.

Now reasoning-only responses are accepted immediately:
- Reasoning stays in the `reasoning` field (semantically correct)
- Content set to "(empty)" — valid non-empty string every provider accepts
- No retries, no compression triggers, no salvage logic
- Session history contains "(empty)" not "" — prevents #2128 session
  poisoning where empty assistant content caused prefill rejections

Removes ~120 lines, adds ~15. Saves 2-3 API calls per reasoning-only
response. Fixes #2128.
2026-04-05 11:30:52 -07:00
nepenth 534511bebb feat(matrix): Tier 1 enhancement — reactions, read receipts, rich formatting, room management
Cherry-picked from PR #4338 by nepenth, resolved against current main.

Adds:
- Processing lifecycle reactions (eyes/checkmark/cross) via MATRIX_REACTIONS env
- Reaction send/receive with ReactionEvent + UnknownEvent fallback for older nio
- Fire-and-forget read receipts on text and media messages
- Message redaction, room history fetch, room creation, user invite
- Presence status control (online/offline/unavailable)
- Emote (/me) and notice message types with HTML rendering
- XSS-hardened markdown-to-HTML converter (strips raw HTML preprocessor,
  sanitizes link URLs against javascript:/data:/vbscript: schemes)
- Comprehensive regex fallback with full block/inline markdown support
- Markdown>=3.6 added to [matrix] extras in pyproject.toml
- 46 new tests covering all features and security hardening
2026-04-05 11:19:54 -07:00
Teknium 20b4060dbf fix: web_extract fast-fail on scrape timeout + summarizer resilience
- Firecrawl scrape: 60s timeout via asyncio.wait_for + to_thread
  (previously could hang indefinitely)
- Summarizer retries: 6 → 2 (one retry), reads timeout from
  auxiliary.web_extract.timeout config (default 360s / 6min)
- Summarizer failure: falls back to truncated raw content (~5000 chars)
  instead of useless error message, with guidance about config/model
- Config default: auxiliary.web_extract.timeout bumped 30 → 360s
  for local model compatibility

Addresses Discord reports of agent hanging during web_extract.
2026-04-05 11:16:45 -07:00
Teknium c100ad874c fix(matrix): E2EE cron delivery via live adapter + HTML formatting + origin fallback
Salvaged from PRs #3767 (chalkers), #5236 (ygd58), #2641 (buntingszn).

Three improvements to Matrix cron delivery:

1. Live adapter path: when the gateway is running, cron delivery now uses
   the connected MatrixAdapter via run_coroutine_threadsafe instead of
   the standalone HTTP PUT. This enables delivery to E2EE rooms where
   the raw HTTP path cannot encrypt. Falls back to standalone on failure.
   Threads adapters + event loop from gateway -> cron ticker -> tick() ->
   _deliver_result(). (from #3767)

2. HTML formatted_body: _send_matrix() now converts markdown to HTML
   using the optional markdown library, with h1-h6 to bold conversion
   for Element X compatibility. Falls back to plain text if markdown
   is not installed. Also adds random bytes to txn_id to prevent
   collisions. (from #5236)

3. Origin fallback: when deliver="origin" but origin is null (jobs
   created via API/scripts), falls back to HOME_CHANNEL env vars
   in order: matrix -> telegram -> discord -> slack. (from #2641)
2026-04-05 11:07:47 -07:00
dlkakbs 36e046e843 fix(gateway): MIME type fallback for Matrix document uploads
Cherry-picked run.py portion from PR #3495 by dlkakbs.
When Matrix sends non-image files (text, YAML, JSON, etc.), the MIME
type may be empty or application/octet-stream. Falls back to
extension-based detection so text files are properly injected into
agent context.
2026-04-05 11:07:47 -07:00
chalkers bec02f3731 fix(matrix): handle encrypted media events and cache decrypted attachments
Cherry-picked from PR #3140 by chalkers, resolved against current main.
Registers RoomEncryptedImage/Audio/Video/File callbacks, decrypts
attachments via nio.crypto, caches all media types (images, audio,
documents), prevents ciphertext URL fallback for encrypted media.
Unifies the separate voice-message download into the main cache block.
Preserves main's MATRIX_REQUIRE_MENTION, auto-thread, and mention
stripping features. Includes 355 lines of encrypted media tests.
2026-04-05 11:07:47 -07:00
binhnt92 b65e67545a fix(gateway): stop Matrix/Mattermost reconnect on permanent auth failures
Cherry-picked from PR #3695 by binhnt92.
Matrix _sync_loop() and Mattermost _ws_loop() were retrying all errors
forever, including permanent auth failures (expired tokens, revoked
access). Now detects M_UNKNOWN_TOKEN, M_FORBIDDEN, 401/403 and stops
instead of spinning. Includes 216 lines of tests.
2026-04-05 11:07:47 -07:00
pjay-io 9d7c288d86 fix(matrix): add filesize to nio.upload() for Synapse compatibility
Cherry-picked from PR #4343 by pjay-io.
Synapse rejects chunked uploads without Content-Length. Adding
filesize=len(data) ensures the upload includes proper sizing.
2026-04-05 11:07:47 -07:00
thakoreh 914f7461dc fix: add missing shutil import for Matrix E2EE setup
Cherry-picked from PR #5136 by thakoreh.
setup_gateway() uses shutil.which('uv') at line 2126 but shutil was
never imported at module level, causing NameError during Matrix E2EE
auto-install. Adds top-level import and regression test.
2026-04-05 11:07:47 -07:00
LucidPaths 70f798043b fix: Ollama Cloud auth, /model switch persistence, and alias tab completion
- Add OLLAMA_API_KEY to credential resolution chain for ollama.com endpoints
- Update requested_provider/_explicit_api_key/_explicit_base_url after /model
  switch so _ensure_runtime_credentials() doesn't revert the switch
- Pass base_url/api_key from fallback config to resolve_provider_client()
- Add DirectAlias system: user-configurable model_aliases in config.yaml
  checked before catalog resolution, with reverse lookup by model ID
- Add /model tab completion showing aliases with provider metadata

Co-authored-by: LucidPaths <LucidPaths@users.noreply.github.com>
2026-04-05 11:06:06 -07:00
Teknium 35d280d0bd feat: coerce tool call arguments to match JSON Schema types (#5265)
LLMs frequently return numbers as strings ("42" instead of 42) and
booleans as strings ("true" instead of true). This causes silent
failures with MCP tools and any tool with strictly-typed parameters.

Added coerce_tool_args() in model_tools.py that runs before every tool
dispatch. For each argument, it checks the tool registry schema and
attempts safe coercion:
  - "42" → 42 when schema says "type": "integer"
  - "3.14" → 3.14 when schema says "type": "number"
  - "true"/"false" → True/False when schema says "type": "boolean"
  - Union types tried in order
  - Original values preserved when coercion fails or is not applicable

Inspired by Block/goose tool argument coercion system.
2026-04-05 10:57:34 -07:00
Teknium e899d6a05d fix: increase default HERMES_AGENT_TIMEOUT from 10min to 30min
Users hitting the 10-minute default during complex tool chains.
Bumps both the execution cap and stale-lock eviction timeout.
Still overridable via HERMES_AGENT_TIMEOUT env var (0 = unlimited).
2026-04-05 10:32:59 -07:00
Teknium 51ed7dc2f3 feat: save oversized tool results to file instead of destructive truncation (#5210)
Previously, tool results exceeding 100K characters were silently chopped
with only a '[Truncated]' notice — the rest of the content was lost
permanently. The model had no way to access the truncated portion.

Now, oversized results are written to HERMES_HOME/cache/tool_responses/
and the model receives:
  - A 1,500-char head preview for immediate context
  - The file path so it can use read_file/search_files on the full output

This preserves the context window protection (inline content stays small)
while making the full data recoverable. Falls back to the old destructive
truncation if the file write fails.

Inspired by Block/goose's large response handler pattern.
2026-04-05 10:29:57 -07:00
Teknium d932980c1a Add gitnexus-explorer optional skill (#5208)
Index codebases with GitNexus and serve an interactive knowledge
graph web UI via Cloudflare tunnel. No sudo required.

Includes:
- Full setup/build/serve/tunnel pipeline
- Zero-dependency Node.js reverse proxy script
- Pitfalls section covering cloudflared config conflicts,
  Vite allowedHosts, Claude Code artifact cleanup, and
  browser memory limits for large repos
2026-04-05 03:00:19 -07:00
Teknium 4976a8b066 feat: /model command — models.dev primary database + --provider flag (#5181)
Full overhaul of the model/provider system.

## What changed
- models.dev (109 providers, 4000+ models) as primary database for provider identity AND model metadata
- --provider flag replaces colon syntax for explicit provider switching
- Full ModelInfo/ProviderInfo dataclasses with context, cost, capabilities, modalities
- HermesOverlay system merges models.dev + Hermes-specific transport/auth/aggregator flags
- User-defined endpoints via config.yaml providers: section
- /model (no args) lists authenticated providers with curated model catalog
- Rich metadata display: context window, max output, cost/M tokens, capabilities
- Config migration: custom_providers list → providers dict (v11→v12)
- AIAgent.switch_model() for in-place model swap preserving conversation

## Files
agent/models_dev.py, hermes_cli/providers.py, hermes_cli/model_switch.py,
hermes_cli/model_normalize.py, cli.py, gateway/run.py, run_agent.py,
hermes_cli/config.py, hermes_cli/commands.py
2026-04-05 01:04:44 -07:00
Teknium cb63b5f381 feat(skills): add popular-web-designs skill with 54 website design systems (#5194)
Curated collection of production-quality design system specifications extracted
from real websites (sourced from VoltAgent/awesome-design-md). Each template
captures a site's complete visual language: colors, typography, components,
layout, shadows, responsive behavior, and agent-ready CSS values.

Hermes-specific adaptations in every template:
- Google Fonts CDN link tags for proprietary font substitutes
- CSS font-family stacks with proper fallbacks
- Integration notes for write_file + generative-widgets workflow
- browser_vision verification reminders

SKILL.md includes categorized catalog, font substitution reference table,
HTML generation pattern, and design-to-use-case matching guide.

Sites: Airbnb, Airtable, Apple, BMW, Cal.com, Claude, Clay, ClickHouse,
Cohere, Coinbase, Composio, Cursor, ElevenLabs, Expo, Figma, Framer,
HashiCorp, IBM, Intercom, Kraken, Linear, Lovable, Minimax, Mintlify,
Miro, Mistral AI, MongoDB, Notion, NVIDIA, Ollama, OpenCode, Pinterest,
PostHog, Raycast, Replicate, Resend, Revolut, RunwayML, Sanity, Sentry,
SpaceX, Spotify, Stripe, Supabase, Superhuman, Together AI, Uber, Vercel,
VoltAgent, Warp, Webflow, Wise, xAI, Zapier
2026-04-05 00:42:55 -07:00
Teknium 0c54da8aaf feat(gateway): live-stream /update output + interactive prompt buttons (#5180)
* feat(gateway): live-stream /update output + forward interactive prompts

Adds real-time output streaming and interactive prompt forwarding for
the gateway /update command, so users on Telegram/Discord/etc see the
full update progress and can respond to prompts (stash restore, config
migration) without needing terminal access.

Changes:

hermes_cli/main.py:
- Add --gateway flag to 'hermes update' argparse
- Add _gateway_prompt() file-based IPC function that writes
  .update_prompt.json and polls for .update_response
- Modify _restore_stashed_changes() to accept optional input_fn
  parameter for gateway mode prompt forwarding
- cmd_update() uses _gateway_prompt when --gateway is set, enabling
  interactive stash restore and config migration prompts

gateway/run.py:
- _handle_update_command: spawn with --gateway flag and
  PYTHONUNBUFFERED=1 for real-time output flushing
- Store session_key in .update_pending.json for cross-restart
  session matching
- Add _update_prompt_pending dict to track sessions awaiting
  update prompt responses
- Replace _watch_for_update_completion with _watch_update_progress:
  streams output chunks every ~4s, detects .update_prompt.json and
  forwards prompts to the user, handles completion/failure/timeout
- Add update prompt interception in _handle_message: when a prompt
  is pending, the user's next message is written to .update_response
  instead of being processed normally
- Preserve _send_update_notification as legacy fallback for
  post-restart cases where adapter isn't available yet

File-based IPC protocol:
- .update_prompt.json: written by update process with prompt text,
  default value, and unique ID
- .update_response: written by gateway with user's answer
- .update_output.txt: existing, now streamed in real-time
- .update_exit_code: existing completion marker

Tests: 16 new tests covering _gateway_prompt IPC, output streaming,
prompt detection/forwarding, message interception, and cleanup.

* feat: interactive buttons for update prompts (Telegram + Discord)

Telegram: Inline keyboard with ✓ Yes / ✗ No buttons. Clicking a button
answers the callback query, edits the message to show the choice, and
writes .update_response directly. CallbackQueryHandler registered on
the update_prompt: prefix.

Discord: UpdatePromptView (discord.ui.View) with green Yes / red No
buttons. Follows the ExecApprovalView pattern — auth check, embed color
update, disabled-after-click. Writes .update_response on click.

All platforms: /approve and /deny (and /yes, /no) now work as shorthand
for yes/no when an update prompt is pending. The text fallback message
instructs users to use these commands. Raw message interception still
works as a fallback for non-command responses.

Gateway watcher checks adapter for send_update_prompt method (class-level
check to avoid MagicMock false positives) and falls back to text prompt
with /approve instructions when unavailable.

* fix: block /update on non-messaging platforms (API, webhooks, ACP)

Add _UPDATE_ALLOWED_PLATFORMS frozenset that explicitly lists messaging
platforms where /update is permitted. API server, webhook, and ACP
platforms get a clear error directing them to run hermes update from
the terminal instead.

ACP and API server already don't reach _handle_message (separate
codepaths), and webhooks have distinct session keys that can't collide
with messaging sessions. This guard is belt-and-suspenders.
2026-04-05 00:28:58 -07:00
Teknium 441ec48802 style: use module-level re import instead of local import re as _re 2026-04-05 00:20:53 -07:00
kshitijk4poor 4437354198 Preserve numeric credential labels in auth removal
Resolve exact label matches before treating digit-only input as a positional index so destructive auth removal does not mis-target credentials named with numeric labels.

Constraint: The CLI remove path must keep supporting existing index-based usage while adding safer label targeting
Rejected: Ban numeric labels | labels are free-form and existing users may already rely on them
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When a destructive command accepts multiple identifier forms, prefer exact identity matches before fallback parsing heuristics
Tested: Focused pytest slice for auth commands, credential pool recovery, and routing (273 passed); py_compile on changed Python files
Not-tested: Full repository pytest suite
2026-04-05 00:20:53 -07:00
kshitijk4poor 65952ac00c Honor provider reset windows in pooled credential failover
Persist structured exhaustion metadata from provider errors, use explicit reset timestamps when available, and expose label-based credential targeting in the auth CLI. This keeps long-lived Codex cooldowns from being misreported as one-hour waits and avoids forcing operators to manage entries by list position alone.

Constraint: Existing credential pool JSON needs to remain backward compatible with stored entries that only record status code and timestamp
Constraint: Runtime recovery must keep the existing retry-then-rotate semantics for 429s while enriching pool state with provider metadata
Rejected: Add a separate credential scheduler subsystem | too large for the Hermes pool architecture and unnecessary for this fix
Rejected: Only change CLI formatting | would leave runtime rotation blind to resets_at and preserve the serial-failure behavior
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve structured rate-limit metadata when new providers expose reset hints; do not collapse back to status-code-only exhaustion tracking
Tested: Focused pytest slice for auth commands, credential pool recovery, and routing (272 passed); py_compile on changed Python files; hermes -w auth list/remove smoke test with temporary HERMES_HOME
Not-tested: Full repository pytest suite, broader gateway/integration flows outside the touched auth and pool paths
2026-04-05 00:20:53 -07:00
Lume ed4a605696 docs: update docstring to mention Fireworks strict validation
Updates _sanitize_tool_calls_for_strict_api docstring to explicitly
mention Fireworks alongside Mistral as strict APIs requiring sanitization.
Also documents the specific fields that are stripped (call_id, response_item_id).
2026-04-05 00:13:25 -07:00
Lume 8545343cba test: add strict API validation tests for Fireworks compatibility
Adds comprehensive tests verifying:
- Fireworks-compatible messages after sanitization
- Codex mode preserves fields for Responses API replay
- Fireworks provider triggers sanitization correctly
- Codex responses mode correctly skips sanitization

Prevents regression of 400 validation errors on strict APIs.
2026-04-05 00:13:25 -07:00
Lume 9be2b18064 test: add test for _should_sanitize_tool_calls()
Adds test verifying that:
- Codex mode returns False (no sanitization needed)
- Chat completions mode returns True (sanitization needed)
- Anthropic mode returns True (sanitization needed)

This ensures strict APIs like Fireworks receive properly sanitized tool_calls.
2026-04-05 00:13:25 -07:00
Lume d90035835b refactor: use _should_sanitize_tool_calls in run_conversation()
Replaces hardcoded Mistral check with the new _should_sanitize_tool_calls()
method. Updates comment to mention Fireworks alongside Mistral as strict
APIs requiring tool_call field sanitization.
2026-04-05 00:13:25 -07:00
Lume 234c01f690 refactor: use _should_sanitize_tool_calls in _handle_max_iterations()
Replaces hardcoded Mistral check with the new _should_sanitize_tool_calls()
method. Ensures summary generation works correctly with Fireworks and
other strict APIs that reject unknown tool_call fields.
2026-04-05 00:13:25 -07:00
Lume 7f6e509199 refactor: use _should_sanitize_tool_calls in flush_memories()
Replaces hardcoded Mistral check with the new _should_sanitize_tool_calls()
method. This ensures tool_calls are sanitized for all strict APIs, not
just Mistral. Prevents 400 errors from Fireworks and other providers.
2026-04-05 00:13:25 -07:00
Lume 560c6ae143 feat: add _should_sanitize_tool_calls() method
Adds a centralized method to determine when tool_calls need sanitization
for strict APIs. Returns True for all APIs except codex_responses mode.
This prevents 400 errors from providers like Fireworks that reject unknown
fields (call_id, response_item_id) in tool_calls.
2026-04-05 00:13:25 -07:00
Teknium 5b003ca4a0 test(redact): add regression tests for lowercase variable redaction (#4367) (#5185)
Add 5 regression tests from PR #4476 (gnanam1990) to prevent re-introducing
the IGNORECASE bug that caused lowercase Python/TypeScript variable assignments
to be incorrectly redacted as secrets. The core fix landed in 6367e1c4.

Tests cover:
- Lowercase Python variable with 'token' in name
- Lowercase Python variable with 'api_key' in name
- TypeScript 'await' not treated as secret value
- TypeScript 'secret' variable assignment
- 'export' prefix preserved for uppercase env vars

Co-authored-by: gnanam1990 <gnanam1990@users.noreply.github.com>
2026-04-05 00:10:16 -07:00
Teknium 0fd3de2674 docs(skill): claude-code v2.2 — add cheat sheet commands, env vars, rules, advanced features (#5158)
Expands the claude-code skill with content from official docs and community
cheat sheets that was missing from v2.0:

Slash commands: /cost, /btw, /plan, /loop, /batch, /security-review,
  /resume, /effort (with auto level), /mcp, /release-notes, /voice details
Keyboard shortcuts: Alt+P (model), Alt+T (thinking), Alt+O (fast mode),
  Ctrl+V (paste image), Ctrl+O (transcript), Ctrl+G (external editor)
Ultrathink keyword for max reasoning on a specific turn
Rules directory: .claude/rules/*.md and ~/.claude/rules/*.md
Auto-memory: ~/.claude/projects/<proj>/memory/ (25KB/200 lines limit)
Environment variables: CLAUDE_CODE_EFFORT_LEVEL, MAX_THINKING_TOKENS,
  CLAUDE_CODE_NO_FLICKER, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB
MCP limits: 2KB tool desc cap, maxResultSizeChars 500K, transport types
Reorganized slash commands into Session/Development/Configuration groups
Reorganized keyboard shortcuts into Controls/Toggles/Multiline groups
2026-04-04 19:15:57 -07:00
Teknium 85cefc7a5a fix(telegram): prevent duplicate message delivery on send timeout (#5153)
TimedOut is a subclass of NetworkError in python-telegram-bot. The
inner retry loop in send() and the outer _send_with_retry() in base.py
both treated it as a transient connection error and retried — but
send_message is not idempotent. When the request reaches Telegram but
the HTTP response times out, the message is already delivered. Retrying
sends duplicates. Worst case: up to 9 copies (inner 3x × outer 3x).

Inner loop (telegram.py):
- Import TimedOut separately, isinstance-check before generic
  NetworkError retry (same pattern as BadRequest carve-out from #3390)
- Re-raise immediately — no retry
- Mark as retryable=False in outer exception handler

Outer loop (base.py):
- Remove 'timeout', 'timed out', 'readtimeout', 'writetimeout' from
  _RETRYABLE_ERROR_PATTERNS (read/write timeouts are delivery-ambiguous)
- Add 'connecttimeout' (safe — connection never established)
- Keep 'network' (other platforms still need it)
- Add _is_timeout_error() + early return to prevent plain-text fallback
  on timeout errors (would also cause duplicate delivery)

Connection errors (ConnectionReset, ConnectError, etc.) are still
retried — these fail before the request reaches the server.

Credit: tmdgusya (PR #3899), barun1997 (PR #3904) for identifying the
bug and proposing fixes.

Closes #3899, closes #3904.
2026-04-04 19:05:34 -07:00
Teknium c8220e69a1 fix: strip MEDIA: directives from streamed gateway messages (#5152)
When streaming is enabled, the GatewayStreamConsumer sends raw text
chunks directly to the platform without post-processing. This causes
MEDIA:/path/to/file tags and [[audio_as_voice]] directives to appear
as visible text in the user's chat instead of being stripped.

The non-streaming path already handles this correctly via
extract_media() in base.py, but the streaming path was missing
equivalent cleanup.

Add _clean_for_display() to GatewayStreamConsumer that strips MEDIA:
tags and internal markers before any text reaches the platform. The
actual media file delivery is unaffected — _deliver_media_from_response()
in gateway/run.py still extracts files from the agent's final_response
(separate from the stream consumer's display text).

Reported by Ao [FotM] on Discord.
2026-04-04 19:05:27 -07:00
Teknium ff544526cd docs(skill): comprehensive claude-code skill rewrite v2.0 (#5155)
Major rewrite of the claude-code orchestration skill from 94 to 460 lines.
Based on official docs research, community guides, and live experimentation.

Key additions:
- Two orchestration modes: Print mode (-p) vs Interactive PTY via tmux
- Detailed PTY dialog handling (trust + permissions bypass patterns)
- Print mode deep dive: JSON output, piped input, session resumption,
  --json-schema, --bare mode for CI
- Complete flag reference (20+ flags organized by category)
- Interactive session patterns with tmux send-keys/capture-pane
- Claude's slash commands and keyboard shortcuts reference
- CLAUDE.md, hooks, custom subagents, MCP, custom commands docs
- Cost/performance tips (effort levels, budget caps, context mgmt)
- 10 specific pitfalls discovered through live testing
- 10 rules for Hermes agents orchestrating Claude Code
2026-04-04 19:00:50 -07:00
memosr 931624feda fix(security): guard cron script against path traversal and redact output
Relative script paths resolved against HERMES_HOME/scripts/ were not
validated to stay within that directory. Paths like '../../etc/passwd'
could escape and be executed as Python.

Fix: resolve the path and verify it stays within scripts_dir using
Path.relative_to(). Also apply redact_sensitive_text() to script stdout
before LLM injection — same pattern as execute_code sandbox output.

Cherry-picked from PR #5093 by memosr (fixes 1 and 3; absolute path
restriction dropped as too restrictive for the feature's design intent).
2026-04-04 17:01:11 -07:00
Teknium aa475aef31 feat: add exit code context for common CLI tools in terminal results (#5144)
When commands like grep, diff, test, or find return non-zero exit codes
that aren't actual errors (grep 1 = no matches, diff 1 = files differ),
the model wastes turns investigating non-problems. This adds an
exit_code_meaning field to the terminal JSON result that explains
informational exit codes, so the agent can move on instead of debugging.

Covers grep/rg/ag/ack (no matches), diff (files differ), find (partial
access), test/[ (condition false), curl (timeouts, DNS, HTTP errors),
and git (context-dependent). Correctly extracts the last command from
pipelines and chains, strips full paths and env var assignments.

The exit_code field itself is unchanged — this is purely additive context.
2026-04-04 16:57:24 -07:00
Teknium 5879b3ef82 fix: move pre_llm_call plugin context to user message, preserve prompt cache (#5146)
Plugin context from pre_llm_call hooks was injected into the system
prompt, breaking the prompt cache prefix every turn when content
changed (typical for memory plugins). Now all plugin context goes
into the current turn's user message — the system prompt stays
identical across turns, preserving cached tokens.

The system prompt is reserved for Hermes internals. Plugins
contribute context alongside the user's input.

Also adds comprehensive documentation for all 6 plugin hooks:
pre_tool_call, post_tool_call, pre_llm_call, post_llm_call,
on_session_start, on_session_end — each with full callback
signatures, parameter tables, firing conditions, and examples.

Supersedes #5138 which identified the same cache-busting bug
and proposed an uncached system suffix approach. This fix goes
further by removing system prompt injection entirely.

Co-identified-by: OutThisLife (PR #5138)
2026-04-04 16:55:44 -07:00
Teknium 96e96a79ad fix: --yolo and other flags silently dropped when placed before 'chat' subcommand (#5145)
When --yolo, -w, -s, -r, -c, and --pass-session-id exist on both the parent
parser and the 'chat' subparser with explicit defaults (default=False or
default=None), argparse's subparser initialization overwrites the parent's
parsed value. So 'hermes --yolo chat' silently drops --yolo, making it appear
broken.

Fix: use default=argparse.SUPPRESS on all duplicated arguments in the chat
subparser. SUPPRESS means 'don't set this attribute if the user didn't
explicitly provide it', so the parent parser's value survives through.

Affected flags: --yolo, --worktree/-w, --skills/-s, --pass-session-id,
--resume/-r, --continue/-c.

Adds 15 regression tests covering flag-before-subcommand, flag-after-subcommand,
no-subcommand, and env var propagation scenarios.
2026-04-04 16:55:13 -07:00
Teknium 55bbf8caba fix: include approval metadata in terminal tool results (#5141)
When a dangerous command is approved (gateway, CLI, or smart approval),
the terminal tool now includes an 'approval' field in the result JSON
so the model knows approval was requested and granted. Previously the
model only saw normal command output with no indication that approval
happened, causing it to hallucinate that the approval system didn't fire.

Changes:
- approval.py: Return user_approved/description in all 3 approval paths
  (gateway blocking, CLI interactive, smart approval)
- terminal_tool.py: Capture approval metadata and inject into both
  foreground and background command results
2026-04-04 16:33:20 -07:00
Fran Fitzpatrick 2556cfdab1 fix(gateway): match Discord mention-stripping behavior in Matrix adapter
Move mention stripping outside the `if not is_dm` guard so mentions
are stripped in DMs too. Remove the bare-mention early return so a
message containing only a mention passes through as empty string,
matching Discord's behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:09:27 -07:00
Fran Fitzpatrick d86be33161 feat(gateway): add MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD support
Bring Matrix feature parity with Discord by adding mention gating and
auto-threading. Both default to true, matching Discord behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:09:27 -07:00
Teknium 569e9f9670 feat: execute_code runs on remote terminal backends (#5088)
* feat: execute_code runs on remote terminal backends (Docker/SSH/Modal/Daytona/Singularity)

When TERMINAL_ENV is not 'local', execute_code now ships the script to
the remote environment and runs it there via the terminal backend --
the same container/sandbox/SSH session used by terminal() and file tools.

Architecture:
- Local backend: unchanged (UDS RPC, subprocess.Popen)
- Remote backends: file-based RPC via execute_oneshot() polling
  - Script writes request files, parent polls and dispatches tool calls
  - Responses written atomically (tmp + rename) via base64/stdin
  - execute_oneshot() bypasses persistent shell lock for concurrency

Changes:
- tools/environments/base.py: add execute_oneshot() (delegates to execute())
- tools/environments/persistent_shell.py: override execute_oneshot() to
  bypass _shell_lock via _execute_oneshot(), enabling concurrent polling
- tools/code_execution_tool.py: add file-based transport to
  generate_hermes_tools_module(), _execute_remote() with full env
  get-or-create, file shipping, RPC poll loop, output post-processing

* fix: use _get_env_config() instead of raw TERMINAL_ENV env var

Read terminal backend type through the canonical config resolution
path (terminal_tool._get_env_config) instead of os.getenv directly.

* fix: use echo piping instead of stdin_data for base64 writes

Modal doesn't reliably deliver stdin_data to chained commands
(base64 -d > file && mv), producing 0-byte files. Switch to
echo 'base64' | base64 -d which works on all backends.

Verified E2E on both Docker and Modal.
2026-04-04 12:57:49 -07:00
Chris Bartholomew 28e1e210ee fix(hindsight): overhaul hindsight memory plugin and memory setup wizard
- Dedicated asyncio event loop for Hindsight async calls (fixes aiohttp session leaks)
- Client caching (reuse instead of creating per-call)
- Local mode daemon management with config change detection and auto-restart
- Memory mode support (hybrid/context/tools) and prefetch method (recall/reflect)
- Proper shutdown with event loop and client cleanup
- Disable HindsightEmbedded.__del__ to avoid GC loop errors
- Update API URLs (app -> ui.hindsight.vectorize.io, api_url -> base_url)
- Setup wizard: conditional fields (when clause), dynamic defaults (default_from)
- Switch dependency install from pip to uv (correct for uv-based venvs)
- Add hindsight-all to plugin.yaml and import mapping
- 12 new tests for dispatch routing and setup field filtering

Original PR #5044 by cdbartholomew.
2026-04-04 12:18:46 -07:00
Teknium 93aa01c71c fix: use main provider model for auxiliary tasks on non-aggregator providers (#5091)
Users on direct API-key providers (Alibaba, DeepSeek, ZAI, etc.) without
an OpenRouter or Nous key would get broken auxiliary tasks (compression,
vision, etc.) because _resolve_auto() only tried aggregator providers
first, then fell back to iterating PROVIDER_REGISTRY with wrong default
model names.

Now _resolve_auto() checks the user's main provider first. If it's not
an aggregator (OpenRouter/Nous), it uses their main model directly for
all auxiliary tasks. Aggregator users still get the cheap gemini-flash
model as before.

Adds _read_main_provider() to read model.provider from config.yaml,
mirroring the existing _read_main_model().

Reported by SkyLinx — Alibaba Coding Plan user getting 400 errors from
google/gemini-3-flash-preview being sent to DashScope.
2026-04-04 12:07:43 -07:00
Teknium 5d0f55cac4 feat(cron): add script field for pre-run data collection (#5082)
Add an optional 'script' parameter to cron jobs that references a Python
script. The script runs before each agent turn, and its stdout is injected
into the prompt as context. This enables stateful monitoring — the script
handles data collection and change detection, the LLM analyzes and reports.

- cron/jobs.py: add script field to create_job(), stored in job dict
- cron/scheduler.py: add _run_job_script() executor with timeout handling,
  inject script output/errors into _build_job_prompt()
- tools/cronjob_tools.py: add script to tool schema, create/update handlers,
  _format_job display
- hermes_cli/cron.py: add --script to create/edit, display in list/edit output
- hermes_cli/main.py: add --script argparse for cron create/edit subcommands
- tests/cron/test_cron_script.py: 20 tests covering job CRUD, script
  execution, path resolution, error handling, prompt injection, tool API

Script paths can be absolute or relative (resolved against ~/.hermes/scripts/).
Scripts run with a 120s timeout. Failures are injected as error context so
the LLM can report the problem. Empty string clears an attached script.
2026-04-04 10:43:39 -07:00
catbusconductor e09e48567e fix(openviking): correct API endpoint paths and response parsing
- Browse: POST /api/v1/browse → GET /api/v1/fs/{ls,tree,stat}
- Read: POST /api/v1/read[/abstract] → GET /api/v1/content/{read,abstract,overview}
- System prompt: result.get('children') → len(result) (API returns list)
- Content: result.get('content') → result is a plain string
- Browse: result['entries'] → result is the list; is_dir → isDir (camelCase)
- Browse: add rel_path and abstract fields to entry output

Based on PR #4742 by catbusconductor. Auth header changes dropped
(already on main via #4825).
2026-04-04 10:40:38 -07:00
Teknium 2aa3f199cb fix(doctor): sync provider checks, add config migration, WAL and mem0 diagnostics (#5077)
Provider coverage:
- Add 6 missing providers to _PROVIDER_ENV_HINTS (Nous, DeepSeek,
  DashScope, HF, OpenCode Zen/Go)
- Add 5 missing providers to API connectivity checks (DeepSeek,
  Hugging Face, Alibaba/DashScope, OpenCode Zen, OpenCode Go)

New diagnostics:
- Config version check — detects outdated config, --fix runs
  non-interactive migration automatically
- Stale root-level config keys — detects provider/base_url at root
  level (known bug source, PR #4329), --fix migrates them into
  the model section
- WAL file size check — warns on >50MB WAL files (indicates missed
  checkpoints from the duplicate close() bug), --fix runs PASSIVE
  checkpoint
- Mem0 memory plugin status — checks API key resolution including
  the env+json merge we just fixed
2026-04-04 10:21:33 -07:00
LucidPaths 6367e1c4c0 fix: remove stale test skips, fix regex backtracking, file search bug, and test flakiness
Bug fixes:
- agent/redact.py: catastrophic regex backtracking in _ENV_ASSIGN_RE — removed
  re.IGNORECASE and changed [A-Z_]* to [A-Z0-9_]* to restrict matching to actual
  env var name chars. Without this, the pattern backtracks exponentially on large
  strings (e.g. 100K tool output), causing test_file_read_guards to time out.
- tools/file_operations.py: over-escaped newline in find -printf format string
  produced literal backslash-n instead of a real newline, breaking file search
  result parsing (total_count always 1, paths concatenated).

Test fixes:
- Remove stale pytestmark.skip from 4 test modules that were blanket-skipped as
  'Hangs in non-interactive environments' but actually run fine:
  - test_413_compression.py (12 tests, 25s)
  - test_file_tools_live.py (71 tests, 24s)
  - test_code_execution.py (61 tests, 99s)
  - test_agent_loop_tool_calling.py (has proper OPENROUTER_API_KEY skip already)
- test_413_compression.py: fix threshold values in 2 preflight compression tests
  where context_length was too small for the compressed output to fit in one pass.
- test_mcp_probe.py: add missing _MCP_AVAILABLE mock so tests work without MCP SDK.
- test_mcp_tool_issue_948.py: inject MCP symbols (StdioServerParameters etc.) when
  SDK is not installed so patch() targets exist.
- test_approve_deny_commands.py: replace time.sleep(0.3) with deterministic polling
  of _gateway_queues — fixes race condition where resolve fires before threads
  register their approval entries, causing the test to hang indefinitely.

Net effect: +256 tests recovered from skip, 8 real failures fixed.
2026-04-04 10:18:57 -07:00
Teknium 77a2aad771 docs: fix stale references across 8 doc pages
Audit found 24+ discrepancies between docs and code. Fixed:

HIGH severity:
- Remove honcho toolset from tools-reference, toolsets-reference, and tools.md
  (converted to memory provider plugin, not a built-in toolset)
- Add note that Honcho is available via plugin

MEDIUM severity:
- Add hermes memory command family to cli-commands.md (setup/status/off)
- Add --clone-all, --clone-from to profile create in cli-commands.md
- Add --max-turns option to hermes chat in cli-commands.md
- Add /btw slash command to slash-commands.md
- Fix profile show example output (remove nonexistent disk usage,
  add .env and SOUL.md status lines)
- Add missing hermes-webhook toolset to toolsets-reference.md
- Add 5 missing providers to fallback-providers.md table
- Add 7 missing providers to providers.md fallback list
- Fix outdated model examples: glm-4-plus→glm-5, moonshot-v1-auto→kimi-for-coding
2026-04-03 23:30:29 -07:00
Teknium 43d3efd5c8 feat: add docker_env config for explicit container environment variables (#4738)
Add docker_env option to terminal config — a dict of key-value pairs that
get set inside Docker containers via -e flags at both container creation
(docker run) and per-command execution (docker exec) time.

This complements docker_forward_env (which reads values dynamically from
the host process environment). docker_env is useful when Hermes runs as a
systemd service without access to the user's shell environment — e.g.
setting SSH_AUTH_SOCK or GNUPGHOME to known stable paths for SSH/GPG
agent socket forwarding.

Precedence: docker_env provides baseline values; docker_forward_env
overrides for the same key.

Config example:
  terminal:
    docker_env:
      SSH_AUTH_SOCK: /run/user/1000/ssh-agent.sock
      GNUPGHOME: /root/.gnupg
    docker_volumes:
      - /run/user/1000/ssh-agent.sock:/run/user/1000/ssh-agent.sock
      - /run/user/1000/gnupg/S.gpg-agent:/root/.gnupg/S.gpg-agent
2026-04-03 23:30:12 -07:00
Stefan Vandermeulen 78ec8b017f style: add debug log for write-back failure in retry path
Address review feedback: replace bare `except: pass` with a debug
log when the post-retry write-back to ~/.claude/.credentials.json
fails. The write-back is best-effort (token is already resolved),
but logging helps troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:26:08 -07:00
Stefan Vandermeulen a70ee1b898 fix: sync OAuth tokens between credential pool and credentials file
OAuth refresh tokens are single-use. When multiple consumers share the
same Anthropic OAuth session (credential pool entries, Claude Code CLI,
multiple Hermes profiles), whichever refreshes first invalidates the
refresh token for all others. This causes a cascade:

1. Pool entry tries to refresh with a consumed refresh token → 400
2. Pool marks the credential as "exhausted" with a 24-hour cooldown
3. All subsequent heartbeats skip the credential entirely
4. The fallback to resolve_anthropic_token() only works while the
   access token in ~/.claude/.credentials.json hasn't expired
5. Once it expires, nothing can auto-recover without manual re-login

Fix:
- Add _sync_anthropic_entry_from_credentials_file() to detect when
  ~/.claude/.credentials.json has a newer refresh token and sync it
  into the pool entry, clearing exhaustion status
- After a successful pool refresh, write the new tokens back to
  ~/.claude/.credentials.json so other consumers stay in sync
- On refresh failure, check if the credentials file has a different
  (newer) refresh token and retry once before marking exhausted
- In _available_entries(), sync exhausted claude_code entries from
  the credentials file before applying the 24-hour cooldown, so a
  manual re-login or external refresh immediately unblocks agents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:26:08 -07:00
Teknium b93fa234df fix: clear ghost status-bar lines on terminal resize (#4960)
* feat: add /branch (/fork) command for session branching

Inspired by Claude Code's /branch command. Creates a copy of the current
session's conversation history in a new session, allowing the user to
explore a different approach without losing the original.

Works like 'git checkout -b' for conversations:
- /branch            — auto-generates a title from the parent session
- /branch my-idea    — uses a custom title
- /fork              — alias for /branch

Implementation:
- CLI: _handle_branch_command() in cli.py
- Gateway: _handle_branch_command() in gateway/run.py
- CommandDef with 'fork' alias in commands.py
- Uses existing parent_session_id field in session DB
- Uses get_next_title_in_lineage() for auto-numbered branches
- 14 tests covering session creation, history copy, parent links,
  title generation, edge cases, and agent sync

* fix: clear ghost status-bar lines on terminal resize

When the terminal shrinks (e.g. un-maximize), the emulator reflows
previously full-width rows (status bar, input rules) into multiple
narrower rows. prompt_toolkit's _on_resize only cursor_up()s by the
stored layout height, missing the extra rows from reflow — leaving
ghost duplicates of the status bar visible.

Fix: monkey-patch Application._on_resize to detect width shrinks,
calculate the extra rows created by reflow, and inflate the renderer's
cursor_pos.y so the erase moves up far enough to clear ghosts.
2026-04-03 22:43:45 -07:00
Octopus f5c212f69b feat: add MiniMax TTS provider support (speech-2.8)
Add MiniMax as a fifth TTS provider alongside Edge TTS, ElevenLabs,
OpenAI, and NeuTTS. Supports speech-2.8-hd (recommended default) and
speech-2.8-turbo models via the MiniMax T2A HTTP API.

Changes:
- Add _generate_minimax_tts() with hex-encoded audio decoding
- Add MiniMax to provider dispatch, requirements check, and Telegram
  Opus compatibility handling
- Add MiniMax to interactive setup wizard with API key prompt
- Update TTS documentation and config example

Configuration:
  tts:
    provider: "minimax"
    minimax:
      model: "speech-2.8-hd"
      voice_id: "English_Graceful_Lady"

Requires MINIMAX_API_KEY environment variable.

API reference: https://platform.minimax.io/docs/api-reference/speech-t2a-http
2026-04-03 22:42:14 -07:00
acsezen 831067c5d3 perf: fix O(n²) catastrophic backtracking in redact regex + reorder file read guard
Two pre-existing issues causing test_file_read_guards timeouts on CI:

1. agent/redact.py: _ENV_ASSIGN_RE used unbounded [A-Z_]* with
   IGNORECASE, matching any letter/underscore to end-of-string at
   each position → O(n²) backtracking on 100K+ char inputs.
   Bounded to {0,50} since env var names are never that long.

2. tools/file_tools.py: redact_sensitive_text() ran BEFORE the
   character-count guard, so oversized content (that would be rejected
   anyway) went through the expensive regex first. Reordered to check
   size limit before redaction.
2026-04-03 22:40:37 -07:00
Teknium 1c0c5d957f fix(gateway): support infinite timeout + periodic notifications + actionable error (#4959)
- HERMES_AGENT_TIMEOUT=0 now means no limit (infinite execution)
- Periodic 'still working' notifications every 10 minutes for long tasks
- Timeout error message now tells users how to increase the limit
- Stale-lock eviction handles infinite timeout correctly (float inf TTL)
2026-04-03 22:37:38 -07:00
Teknium 34308e4de9 docs: improve youtube-content skill structure and workflow
Clearer workflow with validation/chunking steps, expanded description
with trigger terms for better agent matching, tightened error handling.
Fixed stray pipe character in original PR diff.

Based on PR #4778 by fernandezbaptiste.

Co-authored-by: fernandezbaptiste <fernandezbaptiste@users.noreply.github.com>
2026-04-03 22:18:00 -07:00
Teknium ad4feeaf0d feat: wire skills.external_dirs into all remaining discovery paths
The config key skills.external_dirs and core resolution (get_all_skills_dirs,
get_external_skills_dirs in agent/skill_utils.py) already existed but several
code paths still only scanned SKILLS_DIR. Now external dirs are respected
everywhere:

- skills_categories(): scan all dirs for category discovery
- _get_category_from_path(): resolve categories against any skills root
- skill_manager_tool._find_skill(): search all dirs for edit/patch/delete
- credential_files.get_skills_directory_mount(): mount all dirs into
  Docker/Singularity containers (external dirs at external_skills/<idx>)
- credential_files.iter_skills_files(): list files from all dirs for
  Modal/Daytona upload
- tools/environments/ssh.py: rsync all skill dirs to remote hosts
- gateway _check_unavailable_skill(): check disabled skills across all dirs

Usage in config.yaml:
  skills:
    external_dirs:
      - ~/repos/agent-skills/hermes
      - /shared/team-skills
2026-04-03 21:14:42 -07:00
Teknium 5a98ce5973 fix: use clean user message for all memory provider operations (#4940)
When a skill is active, user_message contains the full SKILL.md content
injected by the skill system. This bloated string was being passed to
memory provider sync_all(), queue_prefetch_all(), and prefetch_all(),
causing providers with query size limits (e.g. Honcho's 10K char limit)
to fail.

Both call sites now use original_user_message (the clean user input,
already defined at line 6516) instead of the skill-inflated user_message:

- Pre-turn prefetch (line ~6695): prefetch_all() query
- Post-turn sync (line ~8672): sync_all() + queue_prefetch_all()

Fixes #4889
2026-04-03 20:43:01 -07:00
Teknium 585a3b40ad fix: use 'is not None and != ""' instead of truthiness for mem0.json merge
The original filter (if v) silently drops False and 0, so
'rerank: false' in mem0.json would be ignored. Use explicit
None/empty-string check to preserve intentional falsy values.
2026-04-03 20:42:48 -07:00
Livia Ellen 5e3303b3d8 fix(mem0): merge env vars with mem0.json instead of either/or
When mem0.json exists but is missing the api_key (e.g. after running
`hermes memory setup`), the plugin reports "not available" even though
MEM0_API_KEY is set in .env.  This happens because _load_config()
returns the JSON file contents verbatim, never falling back to env vars.

Use env vars as the base config and let mem0.json override individual
keys on top, so both config sources work together.

Fixes: mem0 plugin shows "not available" despite valid MEM0_API_KEY in .env
2026-04-03 20:42:48 -07:00
Mibayy 14e87325df fix(openviking): send tenant-scoping headers on every request (#4825)
OpenViking is multi-tenant and requires X-OpenViking-Account and
X-OpenViking-User headers. Without them, API calls like POST
/api/v1/search/find fail on authenticated servers.

Add both headers to _VikingClient._headers(), read from env vars
OPENVIKING_ACCOUNT (default: root) and OPENVIKING_USER (default:
default). All instantiation sites inherit the fix automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:32:55 -07:00
Teknium f1c0847145 fix(gateway): restore short preview truncation for all/new tool progress modes (#4935)
The tool_preview_length: 0 (unlimited) config change from e314833c
removed truncation from gateway progress messages in all/new modes.
This caused full terminal commands, code blocks, and file paths to
appear as permanent messages in Telegram -- the old 40-char truncation
was the correct behavior for messaging platforms.

Now:
- all/new modes: always truncate previews to 40 chars (old behavior)
- verbose mode: respects tool_preview_length config for JSON args cap

Reported by Paulclgro and socialsurfer on Discord.
2026-04-03 20:32:01 -07:00
Teknium 8af6a08695 fix: don't treat bare file paths as slash commands
Input like /Users/ironin/file.md:45-46 was routed to process_command()
because it starts with /. Added _looks_like_slash_command() which checks
whether the first word contains additional / characters — commands never
do (/help, /model), paths always do (/Users/foo/bar.md).

Applied to both process_loop routing and handle_enter interrupt bypass.
Preserves prefix matching (/h → /help) since short prefixes still pass
the check.

Based on PR #4782 by iRonin.

Co-authored-by: iRonin <iRonin@users.noreply.github.com>
2026-04-03 20:16:04 -07:00
Teknium fb68c22340 fix(gateway): bypass active-session guard for /approve and /deny commands (#4926)
The base adapter's active-session guard queues all messages when an agent
is running. This creates a deadlock for /approve and /deny: the agent
thread is blocked on threading.Event.wait() in tools/approval.py waiting
for resolve_gateway_approval(), but the /approve command is queued waiting
for the agent to finish.

Dispatch /approve and /deny directly to the message handler (which routes
to gateway/run.py's _handle_approve_command) without going through
_process_message_background — avoids spawning a competing background task
that would mess with session lifecycle/guards.

Fixes #4898
Co-authored-by: mechovation (original diagnosis in PR #4904)
2026-04-03 20:08:37 -07:00
memosr 287ac15efd fix(gateway): write update-pending state atomically to prevent corruption 2026-04-03 18:57:38 -07:00
Teknium cee761ee4a fix: prevent duplicate messages — gateway dedup + partial stream guard (#4878)
* fix(gateway): add message deduplication to Discord and Slack adapters (#4777)

Discord RESUME replays events after reconnects (~7/day observed),
and Slack Socket Mode can redeliver events if the ack was lost.
Neither adapter tracked which messages were already processed,
causing duplicate bot responses.

Add _seen_messages dedup cache (message ID → timestamp) with 5-min
TTL and 2000-entry cap to both adapters, matching the pattern already
used by Mattermost, Matrix, WeCom, Feishu, DingTalk, and Email.

The check goes at the very top of the message handler, before any
other logic, so replayed events are silently dropped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent duplicate messages on partial stream delivery

When streaming fails after tokens are already delivered to the platform,
_interruptible_streaming_api_call re-raised the error into the outer
retry loop, which would make a new API call — creating a duplicate
message.

Now checks deltas_were_sent before re-raising: if partial content was
already streamed, returns a stub response instead. The outer loop treats
the turn as complete (no retry, no fallback, no duplicate).

Inspired by PR #4871 (@trevorgordon981) which identified the bug.
This implementation avoids monkey-patching exception objects and keeps
the fix within the streaming call boundary.

---------

Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:53:52 -07:00
Teknium 36aace34aa fix(opencode-go): strip trailing /v1 from base URL for Anthropic models (#4918)
The Anthropic SDK appends /v1/messages to the base_url, so OpenCode's
base URL https://opencode.ai/zen/go/v1 produced a double /v1 path
(https://opencode.ai/zen/go/v1/v1/messages), causing 404s for MiniMax
models. Strip trailing /v1 when api_mode is anthropic_messages.

Also adds MiMo-V2-Pro, MiMo-V2-Omni, and MiniMax-M2.5 to the OpenCode
Go model lists per their updated docs.

Fixes #4890
2026-04-03 18:47:51 -07:00
Teknium d4bf517b19 test+docs: add group_topics tests and documentation
- 7 new tests covering skill binding, fallthrough, coercion
- Docs section in telegram.md with config format, field reference,
  comparison table, and thread_id discovery tip
2026-04-03 18:20:50 -07:00
Dolf 1cae9ac628 feat(telegram): add group_topics skill binding for supergroup forum topics
Reads config.extra['group_topics'] to bind skills to specific thread_ids
in supergroup/forum chats. Mirrors the dm_topics skill injection pattern
but for group chat_type. Enables per-topic skill auto-loading in Falcon HQ.

Config format:
  platforms.telegram.extra.group_topics:
    - chat_id: -1003853746818
      topics:
        - name: FalconConnect
          thread_id: 5
          skill: falconconnect-architecture
2026-04-03 18:20:50 -07:00
Teknium fb654c15d8 fix: add type hints to session key helpers, extend context-local key to terminal_tool
- Add contextvars.Token[str] type hints to set/reset_current_session_key
- Use get_current_session_key(default='') in terminal_tool.py for background
  process session tracking, fixing the same env var race for concurrent
  gateway sessions spawning background processes
2026-04-03 17:50:01 -07:00
Tranquil-Flow 3bfb39a25f fix(gateway): isolate approval session key per turn 2026-04-03 17:50:01 -07:00
kshitijk4poor 5359921199 refactor: simplify scope validation helpers in google workspace scripts
Fix double file read bug in google_api.py _missing_scopes(), consolidate
redundant _normalize_scope_values into callers, merge duplicate except blocks.
2026-04-03 17:49:18 -07:00
kshitijk4poor 37e2ef6c3f fix: protect profile-scoped google workspace oauth tokens 2026-04-03 17:49:18 -07:00
Teknium 92dcdbff66 fix: clarify interrupt re-queue label, document busy_input_mode behaviour
The '📨 Queued:' label was misleading — it looked like the message was
silently deferred when it was actually being sent immediately after the
interrupt. Changed to ' Sending after interrupt:' with multi-message
count when the user typed several messages during agent execution.

Added comment documenting that this code path only applies when
busy_input_mode == 'interrupt' (the default).

Based on PR #4821 by iRonin.

Co-authored-by: iRonin <iRonin@users.noreply.github.com>
2026-04-03 15:00:05 -07:00
Teknium 3f2180037c fix: also filter session_meta in /session switch restore path
The original PR missed the third CLI restore path — the /session switch
command that loads history via get_messages_as_conversation() without
stripping session_meta entries.
2026-04-03 14:57:33 -07:00
kagura-agent 6bf5946bbe fix: filter transcript-only roles from chat-completions payload (#4715)
Add a provider-agnostic role allowlist guard to _sanitize_api_messages()
that drops messages with roles not accepted by the chat-completions API
(e.g. session_meta). This prevents CLI resume/session restore from
leaking transcript-only metadata into the outgoing messages payload.

Two layers of defense:

1. API-boundary guard: _sanitize_api_messages() now filters messages by
   role allowlist (system/user/assistant/tool/function/developer) before
   the existing orphaned tool-call repair logic. This protects all
   current and future call paths.

2. CLI restore defense-in-depth: Both session restore paths in cli.py
   now strip session_meta entries before loading history into
   conversation_history, matching the existing gateway behavior.

Closes #4715
2026-04-03 14:57:33 -07:00
Hermes Agent bef895b371 fix(memory): preserve holographic prompt and trust score rendering 2026-04-03 14:22:22 -07:00
Teknium 84a875ca02 fix: scope gateway stop/restart to current profile, --all for global kill
gateway stop and restart previously called kill_gateway_processes() which
scans ps aux and kills ALL gateway processes across all profiles. Starting
a profile gateway would nuke the main one (and vice versa).

Now:
- hermes gateway stop → only kills the current profile's gateway (PID file)
- hermes -p work gateway stop → only kills the 'work' profile's gateway
- hermes gateway stop --all → kills every gateway process (old behavior)
- hermes gateway restart → profile-scoped for manual fallback path
- hermes update → discovers and restarts ALL profile gateways (systemctl
  list-units hermes-gateway*) since the code update is shared

Added stop_profile_gateway() which uses the HERMES_HOME-scoped PID file
instead of global process scanning.
2026-04-03 14:21:44 -07:00
Teknium 52ddd6bc64 refactor(skills): consolidate code verification skills into one (#4854)
* chore: release v0.7.0 (2026.4.3)

168 merged PRs, 223 commits, 46 resolved issues, 40+ contributors.

Highlights: pluggable memory providers, credential pools, Camofox browser,
inline diff previews, API server session continuity, ACP MCP registration,
gateway hardening, secret exfiltration blocking.

* refactor(skills): consolidate code-review + verify-code-changes into requesting-code-review

Merge the passive code-review checklist and the automated verification
pipeline (from PR #4459 by @MorAlekss) into a single requesting-code-review
skill. This eliminates model confusion between three overlapping skills.

Now includes:
- Static security scan (grep on diff lines)
- Baseline-aware quality gates (only flag NEW failures)
- Multi-language tool detection (Python, Node, Rust, Go)
- Independent reviewer subagent with fail-closed JSON verdict
- Auto-fix loop with separate fixer agent (max 2 attempts)
- Git checkpoint and [verified] commit convention

Deletes: skills/software-development/code-review/ (absorbed)
Closes: #406 (independent code verification)
2026-04-03 14:13:27 -07:00
Teknium 7def061fee feat: add arcee-ai/trinity-large-thinking to recommended models
Added to OPENROUTER_MODELS and _PROVIDER_MODELS['nous'] lists.
Also added 'trinity' family entry to DEFAULT_CONTEXT_LENGTHS (262K).
2026-04-03 13:45:29 -07:00
CK iRonin.IT de5aacddd2 fix: normalise \r\n and \r line endings in pasted text
Windows (CRLF) and old Mac (CR) line endings are normalised to LF
before the 5-line collapse threshold is checked in handle_paste.

Without this, markdown copied from Windows sources contains \r\n but
the line counter (pasted_text.count('\n')) still works — however
buf.insert_text() leaves bare \r characters in the buffer which some
terminals render by moving the cursor to the start of the line,
making multi-line pastes appear as a single overwritten line.
2026-04-03 13:20:50 -07:00
Teknium b1756084a3 feat: add .zip document support and auto-mount cache dirs into remote backends (#4846)
- Add .zip to SUPPORTED_DOCUMENT_TYPES so gateway platforms (Telegram,
  Slack, Discord) cache uploaded zip files instead of rejecting them.
- Add get_cache_directory_mounts() and iter_cache_files() to
  credential_files.py for host-side cache directory passthrough
  (documents, images, audio, screenshots).
- Docker: bind-mount cache dirs read-only alongside credentials/skills.
  Changes are live (bind mount semantics).
- Modal: mount cache files at sandbox creation + resync before each
  command via _sync_files() with mtime+size change detection.
- Handles backward-compat with legacy dir names (document_cache,
  image_cache, audio_cache, browser_screenshots) via get_hermes_dir().
- Container paths always use the new cache/<subdir> layout regardless
  of host layout.

This replaces the need for a dedicated extract_archive tool (PR #4819)
— the agent can now use standard terminal commands (unzip, tar) on
uploaded files inside remote containers.

Closes: related to PR #4819 by kshitijk4poor
2026-04-03 13:16:26 -07:00
Teknium 8a384628a5 fix(memory): profile-scoped memory isolation and clone support (#4845)
Three fixes for memory+profile isolation bugs:

1. memory_tool.py: Replace module-level MEMORY_DIR constant with
   get_memory_dir() function that calls get_hermes_home() dynamically.
   The old constant was cached at import time and could go stale if
   HERMES_HOME changed after import. Internal MemoryStore methods now
   call get_memory_dir() directly. MEMORY_DIR kept as backward-compat
   alias.

2. profiles.py: profile create --clone now copies MEMORY.md and USER.md
   from the source profile. These curated memory files are part of the
   agent's identity (same as SOUL.md) and should carry over on clone.

3. holographic plugin: initialize() now expands $HERMES_HOME and
   ${HERMES_HOME} in the db_path config value, so users can write
   'db_path: $HERMES_HOME/memory_store.db' and it resolves to the
   active profile directory, not the default home.

Tests updated to mock get_memory_dir() alongside the legacy MEMORY_DIR.
2026-04-03 13:10:11 -07:00
Teknium 4979d77a4a fix: complete browser_tool profile isolation — replace remaining 3 hardcoded HERMES_HOME instances
The original PR fixed 4 of 7 instances. This fixes the remaining 3:
- _launch_local_browser() PATH setup (line 908)
- _start_recording() config read (line 1545)
- _cleanup_old_recordings() path (line 1834)
2026-04-03 13:09:54 -07:00
Dusk1e a09fa690f0 fix: resolve critical stability issues in core, web, and browser tools 2026-04-03 13:09:54 -07:00
Teknium 6d357bb185 fix: regenerate uv.lock to sync with pyproject.toml v0.7.0 (#4842)
uv.lock was stale at v0.5.0 and missing exa-py (core dep), causing
ModuleNotFoundError for Nix flake builds. Also syncs faster-whisper
placement (core → voice extra), adds feishu/debugpy/lark-oapi extras.

Fixes #4648
Credit to @lvnilesh for identifying the issue in PR #4649.
2026-04-03 12:53:45 -07:00
Dat Pham b3319b1252 fix(memory): Fix ByteRover plugin - run brv query synchronously before LLM call
The pipeline prefetch design was firing \`brv query\` in a background
thread *after* each response, meaning the context injected at turn N
was from turn N-1's message — and the first turn got no BRV context
at all. Replace the async prefetch pipeline with a synchronous query
in \`prefetch()\` so recall runs before the first API call on every
turn. Make \`queue_prefetch()\` a no-op and remove the now-unused
pipeline state.
2026-04-03 12:11:29 -07:00
Teknium abf1e98f62 chore: release v0.7.0 (2026.4.3) (#4812)
168 merged PRs, 223 commits, 46 resolved issues, 40+ contributors.

Highlights: pluggable memory providers, credential pools, Camofox browser,
inline diff previews, API server session continuity, ACP MCP registration,
gateway hardening, secret exfiltration blocking.
2026-04-03 11:14:55 -07:00
Teknium e492420df4 fix: route memory provider tools in sequential execution path (#4803)
Memory provider tools (hindsight_retain, honcho_search, etc.) were
advertised to the model via tool schemas but failed with 'Unknown tool'
at execution time. The concurrent path (_invoke_tool) correctly checks
self._memory_manager.has_tool() before falling through to the registry,
but the sequential path (_execute_tool_calls_sequential) was never
updated with this check. Since sequential is the default for single
tool calls, memory provider tools always hit the registry dispatcher
which returns 'Unknown tool' because they're not registered there.

Add the memory_manager dispatch check between the delegate_task handler
and the quiet_mode fallthrough in the sequential path, with proper
spinner/display handling to match the existing pattern.

Reported by KiBenderOP — all memory providers affected (Honcho,
Hindsight, Holographic, etc.).
2026-04-03 10:31:53 -07:00
Teknium 67e3620c5c fix: persist API server sessions to shared SessionDB (state.db) (#4802)
The API server adapter created AIAgent instances without passing
session_db, so conversations via Open WebUI and other OpenAI-compatible
frontends were never persisted to state.db. This meant 'hermes sessions
list' showed no API server sessions — they were effectively stateless.

Changes:
- Add _ensure_session_db() helper for lazy SessionDB initialization
- Pass session_db=self._ensure_session_db() in _create_agent()
- Refactor existing X-Hermes-Session-Id handler to use the shared helper

Sessions now persist with source='api_server' and are visible alongside
CLI and gateway sessions in hermes sessions list/search.
2026-04-03 10:31:11 -07:00
Teknium aecbf7fa4a fix(discord): register /approve and /deny slash commands, wire up button-based approval UI (#4800)
Two fixes for Discord exec approval:

1. Register /approve and /deny as native Discord slash commands so they
   appear in Discord's command picker (autocomplete). Previously they
   were only handled as text commands, so users saw 'no commands found'
   when typing /approve.

2. Wire up the existing ExecApprovalView button UI (was dead code):
   - ExecApprovalView now calls resolve_gateway_approval() to actually
     unblock the waiting agent thread when a button is clicked
   - Gateway's _approval_notify_sync() detects adapters with
     send_exec_approval() and routes through the button UI
   - Added 'Allow Session' button for parity with /approve session
   - send_exec_approval() now accepts session_key and metadata for
     thread support
   - Graceful fallback to text-based /approve prompt if button send fails

Also updates test mocks to include grey/secondary ButtonStyle and
purple Color (used by new button styles).
2026-04-03 10:24:07 -07:00
Teknium 5db630aae4 fix: respect per-platform disabled skills in Telegram menu and gateway dispatch (#4799)
Three interconnected bugs caused `hermes skills config` per-platform
settings to be silently ignored:

1. telegram_menu_commands() never filtered disabled skills — all skills
   consumed menu slots regardless of platform config, hitting Telegram's
   100 command cap. Now loads disabled skills for 'telegram' and excludes
   them from the menu.

2. Gateway skill dispatch executed disabled skills because
   get_skill_commands() (process-global cache) only filters by the global
   disabled list at scan time. Added per-platform check before execution,
   returning an actionable 'skill is disabled' message.

3. get_disabled_skill_names() only checked HERMES_PLATFORM env var, but
   the gateway sets HERMES_SESSION_PLATFORM instead. Added
   HERMES_SESSION_PLATFORM as fallback, plus an explicit platform=
   parameter for callers that know their platform (menu builder, gateway
   dispatch). Also added platform to prompt_builder's skills cache key
   so multi-platform gateways get correct per-platform skill prompts.

Reported by SteveSkedasticity (CLAW community).
2026-04-03 10:10:53 -07:00
Teknium b6f9b70afd fix(gateway): route /approve and /deny through running-agent guard (#4798)
When the agent is blocked on a dangerous command approval (threading.Event
wait inside tools/approval.py), incoming /approve and /deny commands were
falling through to the generic interrupt path instead of being dispatched
to their command handlers. The interrupt sets _interrupt_requested on the
agent, but the agent thread is blocked on event.wait() — not checking the
flag. Result: approval times out after 300s (5 minutes) before executing.

Fix: intercept /approve and /deny in the running-agent early-intercept
block (alongside /stop, /new, /queue) and route directly to
_handle_approve_command / _handle_deny_command.
2026-04-03 09:59:52 -07:00
Teknium 93334b2b92 docs: add community FAQ entries — multi-model workflows, WhatsApp binding, verbose control, skills config, thread sessions, migration, install troubleshooting (#4797)
Addresses common questions from the Nous Research community Discord:
- Multi-model workflows via delegation config
- WhatsApp per-chat binding limitations and workarounds
- Controlling tool progress display on Telegram
- Per-platform skills config and Telegram 100-command limit
- Shared thread sessions across multiple users
- Exporting/migrating Hermes to a new machine
- Permission denied on shell reload after install
- HTTP 400 on first agent run
2026-04-03 09:58:22 -07:00
Teknium d50e5be500 fix: handle None mcp_servers in _get_platform_tools()
When config.yaml has 'mcp_servers:' with no value, YAML parses it as
None. dict.get('mcp_servers', {}) only returns the default when the key
is absent, not when it's explicitly None. Use 'or {}' pattern to handle
both cases, matching the other two assignment sites in the same file.
2026-04-03 09:08:20 -07:00
Teknium cc54818d26 fix(mcp): stability fix pack — reload timeout, shutdown cleanup, event loop handler, OAuth non-blocking (#4757)
Four fixes for MCP server stability issues reported by community member
(terminal lockup, zombie processes, escape sequence pollution, startup hang):

1. MCP reload timeout guard (cli.py): _check_config_mcp_changes now runs
   _reload_mcp in a separate daemon thread with a 30s hard timeout. Previously,
   a hung MCP server could block the process_loop thread indefinitely, freezing
   the entire TUI (user can type but nothing happens, only Ctrl+D/Ctrl+\ work).

2. MCP stdio subprocess PID tracking (mcp_tool.py): Tracks child PIDs spawned
   by stdio_client via before/after snapshots of /proc children. On shutdown,
   _stop_mcp_loop force-kills any tracked PIDs that survived the SDK's graceful
   SIGTERM→SIGKILL cleanup. Prevents zombie MCP server processes from
   accumulating across sessions.

3. MCP event loop exception handler (mcp_tool.py): Installs
   _mcp_loop_exception_handler on the MCP background event loop — same pattern
   as the existing _suppress_closed_loop_errors on prompt_toolkit's loop.
   Suppresses benign 'Event loop is closed' RuntimeError from httpx transport
   __del__ during MCP shutdown. Salvaged from PR #2538 (acsezen).

4. MCP OAuth non-blocking (mcp_oauth.py): Replaces blocking input() call in
   _wait_for_callback with OAuthNonInteractiveError raise. Adds _is_interactive()
   TTY detection. In non-interactive environments, build_oauth_auth() still
   returns a provider (cached tokens + refresh work), but the callback handler
   raises immediately instead of blocking the MCP event loop for 120s. Re-raises
   OAuth setup failures in _run_http so failed servers are reported cleanly
   without blocking others. Salvaged from PRs #4521 (voidborne-d) and #4465
   (heathley).

Closes #2537, closes #4462
Related: #4128, #3436
2026-04-03 02:29:20 -07:00
Teknium f374ae4c61 fix: prevent compression death spiral from API disconnects (#2153) (#4750)
Three fixes for long-running gateway sessions that enter a death spiral
when API disconnects prevent token data collection, which prevents
compression, which causes more disconnects:

Layer 1 — Stale token counter fallback (run_agent.py in-loop):
When last_prompt_tokens is 0 (stale after API disconnect or provider
returned no usage data), fall back to estimate_messages_tokens_rough()
instead of passing 0 to should_compress(), which would never fire.

Layer 2 — Server disconnect heuristic (run_agent.py error handler):
When ReadError/RemoteProtocolError hits a large session (>60% context
or >200 messages), treat it as a context-length error and trigger
compression rather than burning through retries that all fail the
same way.

Layer 3 — Hard message count limit (gateway/run.py hygiene):
Force compression when a session exceeds 400 messages, regardless of
token estimates. This catches runaway growth even when all token-based
checks fail due to missing API data.

Based on the analysis from PR #2157 by ygd58 — the gateway threshold
direction fix (1.4x multiplier) was already resolved on main.
2026-04-03 02:16:46 -07:00
Teknium 8fd9fafc84 fix: handle Anthropic Sonnet long-context tier 429 by reducing to 200k (#4747)
Anthropic returns HTTP 429 'Extra usage is required for long context
requests' when a Claude Max subscription doesn't include the 1M context
tier. This is NOT a transient rate limit — retrying won't help.

Only applies to Sonnet models (Opus 1M is general access). Detects
this specific error before the generic rate-limit handler and:
1. Reduces context_length from 1M to 200k (the standard tier)
2. Triggers context compression to fit
3. Retries with the reduced context

The reduction is session-scoped (not persisted) so it auto-recovers
if the user later enables extra usage on their subscription.

Fixes: Sonnet 4.6 instant rate limits on Claude Max without extra usage
2026-04-03 02:05:02 -07:00
Teknium 26d6083624 fix: correct qwen3.6-plus model slug
Renamed qwen/qwen3.6-plus-preview:free to qwen/qwen3.6-plus:free in both
OPENROUTER_MODELS and _PROVIDER_MODELS['nous'] lists.
2026-04-03 01:56:43 -07:00
Teknium 470c3ea51a fix: handle Anthropic long-context tier 429 by reducing to 200k
Anthropic returns HTTP 429 'Extra usage is required for long context
requests' when a Claude Max subscription doesn't include the 1M context
tier. This is NOT a transient rate limit — retrying won't help.

Detect this specific error before the generic rate-limit handler and:
1. Reduce context_length from 1M to 200k (the standard tier)
2. Trigger context compression to fit
3. Retry with the reduced context

The reduction is session-scoped (not persisted) so it auto-recovers
if the user later enables extra usage on their subscription.

Fixes: Sonnet 4.6 instant rate limits on Claude Max without extra usage
2026-04-03 01:56:43 -07:00
NexVeridian 388241f798 docs(acp): fix zed config 2026-04-03 01:46:45 -07:00
Teknium 67ae7a79df fix: use get_hermes_home(), consolidate git_cmd, update tests
Follow-up for salvaged PR #2352:
- Replace hardcoded Path(os.getenv('HERMES_HOME', ...)) with
  get_hermes_home() from hermes_constants (2 places)
- Consolidate redundant git_cmd_base into the existing git_cmd
  variable, constructed once before fork detection
- Update autostash tests for the unmerged index check added
  in the previous commit
2026-04-03 01:46:42 -07:00
Franci Penov 6b0022bb7b Add fork detection and upstream sync to hermes update
- Detect if origin points to a fork (not NousResearch/hermes-agent)
- Show warning when updating from a fork: origin URL
- After pulling from origin/main on a fork:
  - Prompt to add upstream remote if not present
  - Respect ~/.hermes/.skip_upstream_prompt to avoid repeated prompts
  - Compare origin/main with upstream/main
  - If origin has commits not on upstream, skip (don't trample user's work)
  - If upstream is ahead, pull from upstream and try to sync fork
  - Use --force-with-lease for safe fork syncing

Non-main branches are unaffected - they just pull from origin/{branch}.

Co-authored-by: Avery <avery@hermes-agent.ai>
2026-04-03 01:46:42 -07:00
Teknium 0109547fa2 fix(update): handle conflicted git index during hermes update (#4735)
* fix(gateway): race condition, photo media loss, and flood control in Telegram

Three bugs causing intermittent silent drops, partial responses, and
flood control delays on the Telegram platform:

1. Race condition in handle_message() — _active_sessions was set inside
   the background task, not before create_task(). Two rapid messages
   could both pass the guard and spawn duplicate processing tasks.
   Fix: set _active_sessions synchronously before spawning the task
   (grammY sequentialize / aiogram EventIsolation pattern).

2. Photo media loss on dequeue — when a photo (no caption) was queued
   during active processing and later dequeued, only .text was
   extracted. Empty text → message silently dropped.
   Fix: _build_media_placeholder() creates text context for media-only
   events so they survive the dequeue path.

3. Progress message edits triggered Telegram flood control — rapid tool
   calls edited the progress message every 0.3s, hitting Telegram's
   rate limit (23s+ waits). This blocked progress updates and could
   cause stream consumer timeouts.
   Fix: throttle edits to 1.5s minimum interval, detect flood control
   errors and gracefully degrade to new messages. edit_message() now
   returns failure for flood waits >5s instead of blocking.

* fix(gateway): downgrade empty/None response log from WARNING to DEBUG

This warning fires on every successful streamed response (streaming
delivers the text, handler returns None via already_sent=True) and
on every queued message during active processing. Both are expected
behavior, not error conditions. Downgrade to DEBUG to reduce log noise.

* fix(gateway): prevent stuck sessions with agent timeout and staleness eviction

Three changes to prevent sessions from getting permanently locked:

1. Agent execution timeout (HERMES_AGENT_TIMEOUT, default 10min):
   Wraps run_in_executor with asyncio.wait_for so a hung API call or
   runaway tool can't lock a session indefinitely. On timeout, the
   agent is interrupted and the user gets an actionable error message.

2. Staleness eviction for _running_agents:
   Tracks start timestamps for each session entry. When a new message
   arrives and the entry is older than timeout + 1min grace, it's
   evicted as a leaked lock. Safety net for any cleanup path that
   fails to remove the entry.

3. Cron job timeout (HERMES_CRON_TIMEOUT, default 10min):
   Wraps run_conversation in a ThreadPoolExecutor with timeout so a
   hung cron job doesn't block the ticker thread (and all subsequent
   cron jobs) indefinitely.

Follows grammY runner's per-update timeout pattern and aiogram's
asyncio.wait_for approach for handler deadlines.

* fix(gateway): STT config resolution, stream consumer flood control fallback

Three targeted fixes from user-reported issues:

1. STT config resolution (transcription_tools.py):
   _has_openai_audio_backend() and _resolve_openai_audio_client_config()
   now check stt.openai.api_key/base_url in config.yaml FIRST, before
   falling back to env vars. Fixes voice transcription breaking when
   using a custom OpenAI-compatible endpoint via config.yaml.

2. Stream consumer flood control fallback (stream_consumer.py):
   When an edit fails mid-stream (e.g., Telegram flood control returns
   failure for waits >5s), reset _already_sent to False so the normal
   final send path delivers the complete response. Previously, a
   truncated partial was left as the final message.

3. Telegram edit_message comment alignment (telegram.py):
   Clarify that long flood waits return failure so streaming can fall
   back to a normal final send.

* refactor: simplify and harden PR fixes after review

- Fix cron ThreadPoolExecutor blocking on timeout: use shutdown(wait=False,
  cancel_futures=True) instead of context manager that waits indefinitely
- Extract _dequeue_pending_text() to deduplicate media-placeholder logic
  in interrupt and normal-completion dequeue paths
- Remove hasattr guards for _running_agents_ts: add class-level default
  so partial test construction works without scattered defensive checks
- Move `import concurrent.futures` to top of cron/scheduler.py
- Progress throttle: sleep remaining interval instead of busy-looping
  0.1s (~15 wakeups per 1.5s window → 1 wakeup)
- Deduplicate _load_stt_config() in transcription_tools.py:
  _has_openai_audio_backend() now delegates to _resolve_openai_audio_client_config()

* fix: move class-level attribute after docstring, clarify throttle comment

Follow-up nits for salvaged PR #4577:
- Move _running_agents_ts class attribute below the docstring so
  GatewayRunner.__doc__ is preserved.
- Add clarifying comment explaining the throttle continue behavior
  (batches queued messages during the throttle interval).

* fix(update): handle conflicted git index during hermes update

When the git index has unmerged entries (e.g. from an interrupted
merge or rebase), git stash fails with 'needs merge / could not
write index'. Detect this with git ls-files --unmerged and clear
the conflict state with git reset before attempting the stash.
Working-tree changes are preserved.

Reported by @LLMJunky — package-lock.json conflict from a prior
merge left the index dirty, blocking hermes update entirely.

---------

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
2026-04-03 01:17:12 -07:00
Teknium c66c688727 fix: remove redundant restart message from update launchd path
launchd_restart() already prints stop/start confirmation via its
internal helpers — the extra 'Gateway restarted via launchd' line
was redundant. Update test assertion to match.
2026-04-03 01:16:42 -07:00
Dave Tist 988ecc7420 fix(update): avoid launchd restart race on macOS 2026-04-03 01:16:42 -07:00
kshitijk4poor 7165eff901 fix(whatsapp): add free_response_chats, mention stripping, and interactive message unwrapping
Address feature gaps vs Telegram/Discord/Mattermost adapters:
- free_response_chats whitelist to bypass mention gating per-group
- strip bot @phone mentions from body before forwarding to agent
- unwrap templateMessage/buttonsMessage/listMessage in bridge
- info-level log on successful mention pattern compilation
- use module-level json import instead of inline import in config
- eliminate double _normalize_whatsapp_id call via walrus operator
- hoist botIds computation outside per-message loop in bridge
2026-04-03 01:16:39 -07:00
kshitijk4poor 714e4941b8 fix(whatsapp): enforce require_mention in group chats 2026-04-03 01:16:39 -07:00
Teknium 23addf48d3 fix: allow running gateway service as root for LXC/container environments (#4732)
Previously, `hermes gateway install --system` hard-refused to create a
service running as root, even when explicitly requested via
`--run-as-user root`. This forced LXC/container users (where root is
the only user) to either create throwaway users or comment out the check
in source.

Changes:
- Auto-detected root (no explicit --run-as-user) still raises, but with
  a message explaining how to override
- Explicit `--run-as-user root` now allowed with a warning about
  security implications
- Interactive setup wizard prompt accepts 'root' as a valid username
  (warning comes from _system_service_identity downstream)
- Added tests for all three paths: auto-detected root rejection,
  explicit root allowance, and normal non-root passthrough
2026-04-03 01:14:21 -07:00
kshitijk4poor 4d99305345 fix(cli): surface recent sessions inside /history and /resume
When /history is used in an empty chat or /resume with no argument,
show an inline table of recent resumable sessions with title, preview,
relative timestamp, and session ID instead of a dead-end message.

Table formatting matches the existing hermes sessions list style
(column headers + thin separators, no box drawing).

Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-04-03 00:50:49 -07:00
Teknium a933079564 fix: move class-level attribute after docstring, clarify throttle comment
Follow-up nits for salvaged PR #4577:
- Move _running_agents_ts class attribute below the docstring so
  GatewayRunner.__doc__ is preserved.
- Add clarifying comment explaining the throttle continue behavior
  (batches queued messages during the throttle interval).
2026-04-03 00:50:17 -07:00
kshitijk4poor 0ed28ab80c refactor: simplify and harden PR fixes after review
- Fix cron ThreadPoolExecutor blocking on timeout: use shutdown(wait=False,
  cancel_futures=True) instead of context manager that waits indefinitely
- Extract _dequeue_pending_text() to deduplicate media-placeholder logic
  in interrupt and normal-completion dequeue paths
- Remove hasattr guards for _running_agents_ts: add class-level default
  so partial test construction works without scattered defensive checks
- Move `import concurrent.futures` to top of cron/scheduler.py
- Progress throttle: sleep remaining interval instead of busy-looping
  0.1s (~15 wakeups per 1.5s window → 1 wakeup)
- Deduplicate _load_stt_config() in transcription_tools.py:
  _has_openai_audio_backend() now delegates to _resolve_openai_audio_client_config()
2026-04-03 00:50:17 -07:00
kshitijk4poor 28380e7aed fix(gateway): STT config resolution, stream consumer flood control fallback
Three targeted fixes from user-reported issues:

1. STT config resolution (transcription_tools.py):
   _has_openai_audio_backend() and _resolve_openai_audio_client_config()
   now check stt.openai.api_key/base_url in config.yaml FIRST, before
   falling back to env vars. Fixes voice transcription breaking when
   using a custom OpenAI-compatible endpoint via config.yaml.

2. Stream consumer flood control fallback (stream_consumer.py):
   When an edit fails mid-stream (e.g., Telegram flood control returns
   failure for waits >5s), reset _already_sent to False so the normal
   final send path delivers the complete response. Previously, a
   truncated partial was left as the final message.

3. Telegram edit_message comment alignment (telegram.py):
   Clarify that long flood waits return failure so streaming can fall
   back to a normal final send.
2026-04-03 00:50:17 -07:00
kshitijk4poor 970042deab fix(gateway): prevent stuck sessions with agent timeout and staleness eviction
Three changes to prevent sessions from getting permanently locked:

1. Agent execution timeout (HERMES_AGENT_TIMEOUT, default 10min):
   Wraps run_in_executor with asyncio.wait_for so a hung API call or
   runaway tool can't lock a session indefinitely. On timeout, the
   agent is interrupted and the user gets an actionable error message.

2. Staleness eviction for _running_agents:
   Tracks start timestamps for each session entry. When a new message
   arrives and the entry is older than timeout + 1min grace, it's
   evicted as a leaked lock. Safety net for any cleanup path that
   fails to remove the entry.

3. Cron job timeout (HERMES_CRON_TIMEOUT, default 10min):
   Wraps run_conversation in a ThreadPoolExecutor with timeout so a
   hung cron job doesn't block the ticker thread (and all subsequent
   cron jobs) indefinitely.

Follows grammY runner's per-update timeout pattern and aiogram's
asyncio.wait_for approach for handler deadlines.
2026-04-03 00:50:17 -07:00
kshitijk4poor 9bb83d1298 fix(gateway): downgrade empty/None response log from WARNING to DEBUG
This warning fires on every successful streamed response (streaming
delivers the text, handler returns None via already_sent=True) and
on every queued message during active processing. Both are expected
behavior, not error conditions. Downgrade to DEBUG to reduce log noise.
2026-04-03 00:50:17 -07:00
kshitijk4poor 69f85a4dce fix(gateway): race condition, photo media loss, and flood control in Telegram
Three bugs causing intermittent silent drops, partial responses, and
flood control delays on the Telegram platform:

1. Race condition in handle_message() — _active_sessions was set inside
   the background task, not before create_task(). Two rapid messages
   could both pass the guard and spawn duplicate processing tasks.
   Fix: set _active_sessions synchronously before spawning the task
   (grammY sequentialize / aiogram EventIsolation pattern).

2. Photo media loss on dequeue — when a photo (no caption) was queued
   during active processing and later dequeued, only .text was
   extracted. Empty text → message silently dropped.
   Fix: _build_media_placeholder() creates text context for media-only
   events so they survive the dequeue path.

3. Progress message edits triggered Telegram flood control — rapid tool
   calls edited the progress message every 0.3s, hitting Telegram's
   rate limit (23s+ waits). This blocked progress updates and could
   cause stream consumer timeouts.
   Fix: throttle edits to 1.5s minimum interval, detect flood control
   errors and gracefully degrade to new messages. edit_message() now
   returns failure for flood waits >5s instead of blocking.
2026-04-03 00:50:17 -07:00
Teknium 3659e1f0c2 test(acp): add E2E tests for MCP registration and tool-result reporting
Tests the full ACP flow:
- new_session with mcpServers → config conversion → register_mcp_servers
- prompt → tool_progress_callback → ToolCallStart events
- step_callback with results → ToolCallUpdate with rawOutput
- toolCallId pairing between start and completion events
- server names with slashes/dots sanitized correctly
- all session lifecycle methods (load/resume/fork) register MCP
2026-04-02 20:54:27 -07:00
Teknium 21c2d32471 fix(gateway): normalize step_callback prev_tools for backward compat
The PR changed prev_tools from list[str] to list[dict] with name/result
keys.  The gateway's _step_callback_sync passed this directly to hooks
as 'tool_names', breaking user-authored hooks that call
', '.join(tool_names).

Now:
- 'tool_names' always contains strings (backward-compatible)
- 'tools' carries the enriched dicts for hooks that want results

Also adds summary logging to register_mcp_servers() and comprehensive
tests for all three PR changes:
- sanitize_mcp_name_component edge cases
- register_mcp_servers public API
- _register_session_mcp_servers ACP integration
- step_callback result forwarding
- gateway normalization backward compat
2026-04-02 20:54:27 -07:00
Jack f66b3fe76b fix(acp): include tool results in step_callback for ACP tool_call_update events
The step_callback previously only forwarded tool names as strings,
so build_tool_complete received result=None and ACP tool_call_update
events had empty content/rawOutput. Now prev_tools carries dicts with
both name and result by pairing each tool_call with its matching
tool-role message via tool_call_id.
2026-04-02 20:54:27 -07:00
Jack 9aa82d4807 fix(acp): use raw server name as registry key, only sanitize for tool name prefixes 2026-04-02 20:54:27 -07:00
Jack 9b2fb1cc2e feat(acp): register client-provided MCP servers as agent tools
ACP clients pass MCP server definitions in session/new, load_session,
resume_session, and fork_session. Previously these were accepted but
silently ignored — the agent never connected to them.

This wires the mcp_servers parameter into the existing MCP registration
pipeline (tools/mcp_tool.py) so client-provided servers are connected,
their tools discovered, and the agent's tool surface refreshed before
the first prompt.

Changes:

tools/mcp_tool.py:
- Extract sanitize_mcp_name_component() to replace all non-[A-Za-z0-9_]
  characters (fixes crash when server names contain / or other chars
  that violate provider tool-name validation rules)
- Use it in _convert_mcp_schema, _sync_mcp_toolsets, _build_utility_schemas
- Extract register_mcp_servers(servers: dict) as a public API that takes
  an explicit {name: config} map. discover_mcp_tools() becomes a thin
  wrapper that loads config.yaml and calls register_mcp_servers()

acp_adapter/server.py:
- Add _register_session_mcp_servers() which converts ACP McpServerStdio /
  McpServerHttp / McpServerSse objects to Hermes MCP config dicts,
  registers them via asyncio.to_thread (avoids blocking the ACP event
  loop), then rebuilds agent.tools, valid_tool_names, and invalidates
  the cached system prompt
- Call it from new_session, load_session, resume_session, fork_session

Tested with Eden (theproxycompany.com) as ACP client — 5 MCP servers
(HTTP + stdio) registered successfully, 110 tools available to the agent.
2026-04-02 20:54:27 -07:00
Erosika 29c98e8f83 feat(honcho): add configurable observation mode (unified/directional)
Adds observationMode config field to HonchoClientConfig:
- 'unified' (default): user peer self-observations, all agents share one pool
- 'directional': AI peer observes user, each agent keeps its own view

Changes:
- client.py: observation_mode field, _normalize_observation_mode(), config resolution
- session.py: add_peers respects mode (peer observation flags), dialectic_query
  routes through correct peer, create_conclusion uses correct observer
2026-04-02 20:38:36 -07:00
Erosika 9e0fc62650 feat(honcho): restore full integration parity in memory provider plugin
Implements all features from the post-merge Honcho plugin spec:

B1: recall_mode support (context/tools/hybrid)
B2: peer_memory_mode gating (stub for ABC suppression mechanism)
B3: resolve_session_name() session key resolution
B4: first-turn context baking in system_prompt_block()
B5: cost-awareness (cadence, injection frequency, reasoning cap)
B6: memory file migration in initialize()
B7: pre-warming context at init

Ports from open PRs:
- #3265: token budget enforcement in prefetch()
- #4053: cron guard (skip activation for cron/flush sessions)
- #2645: baseUrl-only flow verified in is_available()
- #1969: aiPeer sync from SOUL.md
- #1957: lazy session init in tools mode

Single file change: plugins/memory/honcho/__init__.py
No modifications to client.py, session.py, or any files outside the plugin.
2026-04-02 20:38:36 -07:00
Teknium 924bc67eee feat(memory): pluggable memory provider interface with profile isolation, review fixes, and honcho CLI restoration (#4623)
* feat(memory): add pluggable memory provider interface with profile isolation

Introduces a pluggable MemoryProvider ABC so external memory backends can
integrate with Hermes without modifying core files. Each backend becomes a
plugin implementing a standard interface, orchestrated by MemoryManager.

Key architecture:
- agent/memory_provider.py — ABC with core + optional lifecycle hooks
- agent/memory_manager.py — single integration point in the agent loop
- agent/builtin_memory_provider.py — wraps existing MEMORY.md/USER.md

Profile isolation fixes applied to all 6 shipped plugins:
- Cognitive Memory: use get_hermes_home() instead of raw env var
- Hindsight Memory: check $HERMES_HOME/hindsight/config.json first,
  fall back to legacy ~/.hindsight/ for backward compat
- Hermes Memory Store: replace hardcoded ~/.hermes paths with
  get_hermes_home() for config loading and DB path defaults
- Mem0 Memory: use get_hermes_home() instead of raw env var
- RetainDB Memory: auto-derive profile-scoped project name from
  hermes_home path (hermes-<profile>), explicit env var overrides
- OpenViking Memory: read-only, no local state, isolation via .env

MemoryManager.initialize_all() now injects hermes_home into kwargs so
every provider can resolve profile-scoped storage without importing
get_hermes_home() themselves.

Plugin system: adds register_memory_provider() to PluginContext and
get_plugin_memory_providers() accessor.

Based on PR #3825. 46 tests (37 unit + 5 E2E + 4 plugin registration).

* refactor(memory): drop cognitive plugin, rewrite OpenViking as full provider

Remove cognitive-memory plugin (#727) — core mechanics are broken:
decay runs 24x too fast (hourly not daily), prefetch uses row ID as
timestamp, search limited by importance not similarity.

Rewrite openviking-memory plugin from a read-only search wrapper into
a full bidirectional memory provider using the complete OpenViking
session lifecycle API:

- sync_turn: records user/assistant messages to OpenViking session
  (threaded, non-blocking)
- on_session_end: commits session to trigger automatic memory extraction
  into 6 categories (profile, preferences, entities, events, cases,
  patterns)
- prefetch: background semantic search via find() endpoint
- on_memory_write: mirrors built-in memory writes to the session
- is_available: checks env var only, no network calls (ABC compliance)

Tools expanded from 3 to 5:
- viking_search: semantic search with mode/scope/limit
- viking_read: tiered content (abstract ~100tok / overview ~2k / full)
- viking_browse: filesystem-style navigation (list/tree/stat)
- viking_remember: explicit memory storage via session
- viking_add_resource: ingest URLs/docs into knowledge base

Uses direct HTTP via httpx (no openviking SDK dependency needed).
Response truncation on viking_read to prevent context flooding.

* fix(memory): harden Mem0 plugin — thread safety, non-blocking sync, circuit breaker

- Remove redundant mem0_context tool (identical to mem0_search with
  rerank=true, top_k=5 — wastes a tool slot and confuses the model)
- Thread sync_turn so it's non-blocking — Mem0's server-side LLM
  extraction can take 5-10s, was stalling the agent after every turn
- Add threading.Lock around _get_client() for thread-safe lazy init
  (prefetch and sync threads could race on first client creation)
- Add circuit breaker: after 5 consecutive API failures, pause calls
  for 120s instead of hammering a down server every turn. Auto-resets
  after cooldown. Logs a warning when tripped.
- Track success/failure in prefetch, sync_turn, and all tool calls
- Wait for previous sync to finish before starting a new one (prevents
  unbounded thread accumulation on rapid turns)
- Clean up shutdown to join both prefetch and sync threads

* fix(memory): enforce single external memory provider limit

MemoryManager now rejects a second non-builtin provider with a warning.
Built-in memory (MEMORY.md/USER.md) is always accepted. Only ONE
external plugin provider is allowed at a time. This prevents tool
schema bloat (some providers add 3-5 tools each) and conflicting
memory backends.

The warning message directs users to configure memory.provider in
config.yaml to select which provider to activate.

Updated all 47 tests to use builtin + one external pattern instead
of multiple externals. Added test_second_external_rejected to verify
the enforcement.

* feat(memory): add ByteRover memory provider plugin

Implements the ByteRover integration (from PR #3499 by hieuntg81) as a
MemoryProvider plugin instead of direct run_agent.py modifications.

ByteRover provides persistent memory via the brv CLI — a hierarchical
knowledge tree with tiered retrieval (fuzzy text then LLM-driven search).
Local-first with optional cloud sync.

Plugin capabilities:
- prefetch: background brv query for relevant context
- sync_turn: curate conversation turns (threaded, non-blocking)
- on_memory_write: mirror built-in memory writes to brv
- on_pre_compress: extract insights before context compression

Tools (3):
- brv_query: search the knowledge tree
- brv_curate: store facts/decisions/patterns
- brv_status: check CLI version and context tree state

Profile isolation: working directory at $HERMES_HOME/byterover/ (scoped
per profile). Binary resolution cached with thread-safe double-checked
locking. All write operations threaded to avoid blocking the agent
(curate can take 120s with LLM processing).

* fix(memory): thread remaining sync_turns, fix holographic, add config key

Plugin fixes:
- Hindsight: thread sync_turn (was blocking up to 30s via _run_in_thread)
- RetainDB: thread sync_turn (was blocking on HTTP POST)
- Both: shutdown now joins sync threads alongside prefetch threads

Holographic retrieval fixes:
- reason(): removed dead intersection_key computation (bundled but never
  used in scoring). Now reuses pre-computed entity_residuals directly,
  moved role_content encoding outside the inner loop.
- contradict(): added _MAX_CONTRADICT_FACTS=500 scaling guard. Above
  500 facts, only checks the most recently updated ones to avoid O(n^2)
  explosion (~125K comparisons at 500 is acceptable).

Config:
- Added memory.provider key to DEFAULT_CONFIG ("" = builtin only).
  No version bump needed (deep_merge handles new keys automatically).

* feat(memory): extract Honcho as a MemoryProvider plugin

Creates plugins/honcho-memory/ as a thin adapter over the existing
honcho_integration/ package. All 4 Honcho tools (profile, search,
context, conclude) move from the normal tool registry to the
MemoryProvider interface.

The plugin delegates all work to HonchoSessionManager — no Honcho
logic is reimplemented. It uses the existing config chain:
$HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.

Lifecycle hooks:
- initialize: creates HonchoSessionManager via existing client factory
- prefetch: background dialectic query
- sync_turn: records messages + flushes to API (threaded)
- on_memory_write: mirrors user profile writes as conclusions
- on_session_end: flushes all pending messages

This is a prerequisite for the MemoryManager wiring in run_agent.py.
Once wired, Honcho goes through the same provider interface as all
other memory plugins, and the scattered Honcho code in run_agent.py
can be consolidated into the single MemoryManager integration point.

* feat(memory): wire MemoryManager into run_agent.py

Adds 8 integration points for the external memory provider plugin,
all purely additive (zero existing code modified):

1. Init (~L1130): Create MemoryManager, find matching plugin provider
   from memory.provider config, initialize with session context
2. Tool injection (~L1160): Append provider tool schemas to self.tools
   and self.valid_tool_names after memory_manager init
3. System prompt (~L2705): Add external provider's system_prompt_block
   alongside existing MEMORY.md/USER.md blocks
4. Tool routing (~L5362): Route provider tool calls through
   memory_manager.handle_tool_call() before the catchall handler
5. Memory write bridge (~L5353): Notify external provider via
   on_memory_write() when the built-in memory tool writes
6. Pre-compress (~L5233): Call on_pre_compress() before context
   compression discards messages
7. Prefetch (~L6421): Inject provider prefetch results into the
   current-turn user message (same pattern as Honcho turn context)
8. Turn sync + session end (~L8161, ~L8172): sync_all() after each
   completed turn, queue_prefetch_all() for next turn, on_session_end()
   + shutdown_all() at conversation end

All hooks are wrapped in try/except — a failing provider never breaks
the agent. The existing memory system, Honcho integration, and all
other code paths are completely untouched.

Full suite: 7222 passed, 4 pre-existing failures.

* refactor(memory): remove legacy Honcho integration from core

Extracts all Honcho-specific code from run_agent.py, model_tools.py,
toolsets.py, and gateway/run.py. Honcho is now exclusively available
as a memory provider plugin (plugins/honcho-memory/).

Removed from run_agent.py (-457 lines):
- Honcho init block (session manager creation, activation, config)
- 8 Honcho methods: _honcho_should_activate, _strip_honcho_tools,
  _activate_honcho, _register_honcho_exit_hook, _queue_honcho_prefetch,
  _honcho_prefetch, _honcho_save_user_observation, _honcho_sync
- _inject_honcho_turn_context module-level function
- Honcho system prompt block (tool descriptions, CLI commands)
- Honcho context injection in api_messages building
- Honcho params from __init__ (honcho_session_key, honcho_manager,
  honcho_config)
- HONCHO_TOOL_NAMES constant
- All honcho-specific tool dispatch forwarding

Removed from other files:
- model_tools.py: honcho_tools import, honcho params from handle_function_call
- toolsets.py: honcho toolset definition, honcho tools from core tools list
- gateway/run.py: honcho params from AIAgent constructor calls

Removed tests (-339 lines):
- 9 Honcho-specific test methods from test_run_agent.py
- TestHonchoAtexitFlush class from test_exit_cleanup_interrupt.py

Restored two regex constants (_SURROGATE_RE, _BUDGET_WARNING_RE) that
were accidentally removed during the honcho function extraction.

The honcho_integration/ package is kept intact — the plugin delegates
to it. tools/honcho_tools.py registry entries are now dead code (import
commented out in model_tools.py) but the file is preserved for reference.

Full suite: 7207 passed, 4 pre-existing failures. Zero regressions.

* refactor(memory): restructure plugins, add CLI, clean gateway, migration notice

Plugin restructure:
- Move all memory plugins from plugins/<name>-memory/ to plugins/memory/<name>/
  (byterover, hindsight, holographic, honcho, mem0, openviking, retaindb)
- New plugins/memory/__init__.py discovery module that scans the directory
  directly, loading providers by name without the general plugin system
- run_agent.py uses load_memory_provider() instead of get_plugin_memory_providers()

CLI wiring:
- hermes memory setup — interactive curses picker + config wizard
- hermes memory status — show active provider, config, availability
- hermes memory off — disable external provider (built-in only)
- hermes honcho — now shows migration notice pointing to hermes memory setup

Gateway cleanup:
- Remove _get_or_create_gateway_honcho (already removed in prev commit)
- Remove _shutdown_gateway_honcho and _shutdown_all_gateway_honcho methods
- Remove all calls to shutdown methods (4 call sites)
- Remove _honcho_managers/_honcho_configs dict references

Dead code removal:
- Delete tools/honcho_tools.py (279 lines, import was already commented out)
- Delete tests/gateway/test_honcho_lifecycle.py (131 lines, tested removed methods)
- Remove if False placeholder from run_agent.py

Migration:
- Honcho migration notice on startup: detects existing honcho.json or
  ~/.honcho/config.json, prints guidance to run hermes memory setup.
  Only fires when memory.provider is not set and not in quiet mode.

Full suite: 7203 passed, 4 pre-existing failures. Zero regressions.

* feat(memory): standardize plugin config + add per-plugin documentation

Config architecture:
- Add save_config(values, hermes_home) to MemoryProvider ABC
- Honcho: writes to $HERMES_HOME/honcho.json (SDK native)
- Mem0: writes to $HERMES_HOME/mem0.json
- Hindsight: writes to $HERMES_HOME/hindsight/config.json
- Holographic: writes to config.yaml under plugins.hermes-memory-store
- OpenViking/RetainDB/ByteRover: env-var only (default no-op)

Setup wizard (hermes memory setup):
- Now calls provider.save_config() for non-secret config
- Secrets still go to .env via env vars
- Only memory.provider activation key goes to config.yaml

Documentation:
- README.md for each of the 7 providers in plugins/memory/<name>/
- Requirements, setup (wizard + manual), config reference, tools table
- Consistent format across all providers

The contract for new memory plugins:
- get_config_schema() declares all fields (REQUIRED)
- save_config() writes native config (REQUIRED if not env-var-only)
- Secrets use env_var field in schema, written to .env by wizard
- README.md in the plugin directory

* docs: add memory providers user guide + developer guide

New pages:
- user-guide/features/memory-providers.md — comprehensive guide covering
  all 7 shipped providers (Honcho, OpenViking, Mem0, Hindsight,
  Holographic, RetainDB, ByteRover). Each with setup, config, tools,
  cost, and unique features. Includes comparison table and profile
  isolation notes.
- developer-guide/memory-provider-plugin.md — how to build a new memory
  provider plugin. Covers ABC, required methods, config schema,
  save_config, threading contract, profile isolation, testing.

Updated pages:
- user-guide/features/memory.md — replaced Honcho section with link to
  new Memory Providers page
- user-guide/features/honcho.md — replaced with migration redirect to
  the new Memory Providers page
- sidebars.ts — added both new pages to navigation

* fix(memory): auto-migrate Honcho users to memory provider plugin

When honcho.json or ~/.honcho/config.json exists but memory.provider
is not set, automatically set memory.provider: honcho in config.yaml
and activate the plugin. The plugin reads the same config files, so
all data and credentials are preserved. Zero user action needed.

Persists the migration to config.yaml so it only fires once. Prints
a one-line confirmation in non-quiet mode.

* fix(memory): only auto-migrate Honcho when enabled + credentialed

Check HonchoClientConfig.enabled AND (api_key OR base_url) before
auto-migrating — not just file existence. Prevents false activation
for users who disabled Honcho, stopped using it (config lingers),
or have ~/.honcho/ from a different tool.

* feat(memory): auto-install pip dependencies during hermes memory setup

Reads pip_dependencies from plugin.yaml, checks which are missing,
installs them via pip before config walkthrough. Also shows install
guidance for external_dependencies (e.g. brv CLI for ByteRover).

Updated all 7 plugin.yaml files with pip_dependencies:
- honcho: honcho-ai
- mem0: mem0ai
- openviking: httpx
- hindsight: hindsight-client
- holographic: (none)
- retaindb: requests
- byterover: (external_dependencies for brv CLI)

* fix: remove remaining Honcho crash risks from cli.py and gateway

cli.py: removed Honcho session re-mapping block (would crash importing
deleted tools/honcho_tools.py), Honcho flush on compress, Honcho
session display on startup, Honcho shutdown on exit, honcho_session_key
AIAgent param.

gateway/run.py: removed honcho_session_key params from helper methods,
sync_honcho param, _honcho.shutdown() block.

tests: fixed test_cron_session_with_honcho_key_skipped (was passing
removed honcho_key param to _flush_memories_for_session).

* fix: include plugins/ in pyproject.toml package list

Without this, plugins/memory/ wouldn't be included in non-editable
installs. Hermes always runs from the repo checkout so this is belt-
and-suspenders, but prevents breakage if the install method changes.

* fix(memory): correct pip-to-import name mapping for dep checks

The heuristic dep.replace('-', '_') fails for packages where the pip
name differs from the import name: honcho-ai→honcho, mem0ai→mem0,
hindsight-client→hindsight_client. Added explicit mapping table so
hermes memory setup doesn't try to reinstall already-installed packages.

* chore: remove dead code from old plugin memory registration path

- hermes_cli/plugins.py: removed register_memory_provider(),
  _memory_providers list, get_plugin_memory_providers() — memory
  providers now use plugins/memory/ discovery, not the general plugin system
- hermes_cli/main.py: stripped 74 lines of dead honcho argparse
  subparsers (setup, status, sessions, map, peer, mode, tokens,
  identity, migrate) — kept only the migration redirect
- agent/memory_provider.py: updated docstring to reflect new
  registration path
- tests: replaced TestPluginMemoryProviderRegistration with
  TestPluginMemoryDiscovery that tests the actual plugins/memory/
  discovery system. Added 3 new tests (discover, load, nonexistent).

* chore: delete dead honcho_integration/cli.py and its tests

cli.py (794 lines) was the old 'hermes honcho' command handler — nobody
calls it since cmd_honcho was replaced with a migration redirect.

Deleted tests that imported from removed code:
- tests/honcho_integration/test_cli.py (tested _resolve_api_key)
- tests/honcho_integration/test_config_isolation.py (tested CLI config paths)
- tests/tools/test_honcho_tools.py (tested the deleted tools/honcho_tools.py)

Remaining honcho_integration/ files (actively used by the plugin):
- client.py (445 lines) — config loading, SDK client creation
- session.py (991 lines) — session management, queries, flush

* refactor: move honcho_integration/ into the honcho plugin

Moves client.py (445 lines) and session.py (991 lines) from the
top-level honcho_integration/ package into plugins/memory/honcho/.
No Honcho code remains in the main codebase.

- plugins/memory/honcho/client.py — config loading, SDK client creation
- plugins/memory/honcho/session.py — session management, queries, flush
- Updated all imports: run_agent.py (auto-migration), hermes_cli/doctor.py,
  plugin __init__.py, session.py cross-import, all tests
- Removed honcho_integration/ package and pyproject.toml entry
- Renamed tests/honcho_integration/ → tests/honcho_plugin/

* docs: update architecture + gateway-internals for memory provider system

- architecture.md: replaced honcho_integration/ with plugins/memory/
- gateway-internals.md: replaced Honcho-specific session routing and
  flush lifecycle docs with generic memory provider interface docs

* fix: update stale mock path for resolve_active_host after honcho plugin migration

* fix(memory): address review feedback — P0 lifecycle, ABC contract, honcho CLI restore

Review feedback from Honcho devs (erosika):

P0 — Provider lifecycle:
- Remove on_session_end() + shutdown_all() from run_conversation() tail
  (was killing providers after every turn in multi-turn sessions)
- Add shutdown_memory_provider() method on AIAgent for callers
- Wire shutdown into CLI atexit, reset_conversation, gateway stop/expiry

Bug fixes:
- Remove sync_honcho=False kwarg from /btw callsites (TypeError crash)
- Fix doctor.py references to dead 'hermes honcho setup' command
- Cache prefetch_all() before tool loop (was re-calling every iteration)

ABC contract hardening (all backwards-compatible):
- Add session_id kwarg to prefetch/sync_turn/queue_prefetch
- Make on_pre_compress() return str (provider insights in compression)
- Add **kwargs to on_turn_start() for runtime context
- Add on_delegation() hook for parent-side subagent observation
- Document agent_context/agent_identity/agent_workspace kwargs on
  initialize() (prevents cron corruption, enables profile scoping)
- Fix docstring: single external provider, not multiple

Honcho CLI restoration:
- Add plugins/memory/honcho/cli.py (from main's honcho_integration/cli.py
  with imports adapted to plugin path)
- Restore full hermes honcho command with all subcommands (status, peer,
  mode, tokens, identity, enable/disable, sync, peers, --target-profile)
- Restore auto-clone on profile creation + sync on hermes update
- hermes honcho setup now redirects to hermes memory setup

* fix(memory): wire on_delegation, skip_memory for cron/flush, fix ByteRover return type

- Wire on_delegation() in delegate_tool.py — parent's memory provider
  is notified with task+result after each subagent completes
- Add skip_memory=True to cron scheduler (prevents cron system prompts
  from corrupting user representations — closes #4052)
- Add skip_memory=True to gateway flush agent (throwaway agent shouldn't
  activate memory provider)
- Fix ByteRover on_pre_compress() return type: None -> str

* fix(honcho): port profile isolation fixes from PR #4632

Ports 5 bug fixes found during profile testing (erosika's PR #4632):

1. 3-tier config resolution — resolve_config_path() now checks
   $HERMES_HOME/honcho.json → ~/.hermes/honcho.json → ~/.honcho/config.json
   (non-default profiles couldn't find shared host blocks)

2. Thread host=_host_key() through from_global_config() in cmd_setup,
   cmd_status, cmd_identity (--target-profile was being ignored)

3. Use bare profile name as aiPeer (not host key with dots) — Honcho's
   peer ID pattern is ^[a-zA-Z0-9_-]+$, dots are invalid

4. Wrap add_peers() in try/except — was fatal on new AI peers, killed
   all message uploads for the session

5. Gate Honcho clone behind --clone/--clone-all on profile create
   (bare create should be blank-slate)

Also: sanitize assistant_peer_id via _sanitize_id()

* fix(tests): add module cleanup fixture to test_cli_provider_resolution

test_cli_provider_resolution._import_cli() wipes tools.*, cli, and
run_agent from sys.modules to force fresh imports, but had no cleanup.
This poisoned all subsequent tests on the same xdist worker — mocks
targeting tools.file_tools, tools.send_message_tool, etc. patched the
NEW module object while already-imported functions still referenced
the OLD one. Caused ~25 cascade failures: send_message KeyError,
process_registry FileNotFoundError, file_read_guards timeouts,
read_loop_detection file-not-found, mcp_oauth None port, and
provider_parity/codex_execution stale tool lists.

Fix: autouse fixture saves all affected modules before each test and
restores them after, matching the pattern in
test_managed_browserbase_and_modal.py.
2026-04-02 15:33:51 -07:00
Teknium e0b2bdb089 fix: webhook platform support — skip home channel prompt, disable tool progress (salvage #4363) (#4660)
Cherry-picked from PR #4363 by @bennyhodl with follow-up fixes:

- Skip 'No home channel' prompt for webhook platform (webhooks deliver
  to configured targets, not a home channel)
- Disable tool progress for webhooks (no message editing support)
- Add webhook to PLATFORMS in tools_config.py and skills_config.py
- Add hermes-webhook toolset to toolsets.py + hermes-gateway includes
- Removed overly aggressive <50 char content filter that blocked
  legitimate short responses (tool progress already handled at source)

Co-authored-by: bennyhodl <bennyhodl@users.noreply.github.com>
2026-04-02 14:00:22 -07:00
SHL0MS 6d68fbf756 Merge pull request #4654 from SHL0MS/skill/research-paper-writing
Replace ml-paper-writing with research-paper-writing: full end-to-end research pipeline
2026-04-02 13:24:12 -07:00
SHL0MS b86647c295 Replace ml-paper-writing with research-paper-writing: full research pipeline skill
Replaces the writing-focused ml-paper-writing skill (940 lines) with a
complete end-to-end research paper pipeline (1,599 lines SKILL.md + 3,184
lines across 7 reference files).

New content:
- Full 8-phase pipeline: project setup, literature review, experiment
  design, execution/monitoring, analysis, paper drafting, review/revision,
  submission preparation
- Iterative refinement strategy guide from autoreason research (when to use
  autoreason vs critique-and-revise vs single-pass, model selection)
- Hermes agent integration: delegate_task parallel drafting, cronjob
  monitoring, memory/todo state management, skill composition
- Professional LaTeX tooling: microtype, siunitx, TikZ diagram patterns,
  algorithm2e, subcaption, latexdiff, SciencePlots
- Human evaluation design: annotation protocols, inter-annotator agreement,
  crowdsourcing platforms
- Title, Figure 1, conclusion, appendix strategy, page budget management
- Anonymization checklist, rebuttal writing, camera-ready preparation
- AAAI and COLM venue coverage (checklists, reviewer guidelines)

Preserved from ml-paper-writing:
- All writing philosophy (Nanda, Farquhar, Gopen & Swan, Lipton, Perez)
- Citation verification workflow (5-step mandatory process)
- All 6 conference templates (NeurIPS, ICML, ICLR, ACL, AAAI, COLM)
- Conference requirements, format conversion workflow
- Proactivity/collaboration guidance

Bug fixes in inherited reference files:
- BibLaTeX recommendation now correctly says natbib for conferences
- Bare except clauses fixed to except Exception
- Jinja2 template tags removed from citation-workflow.md
- Stale date caveats added to reviewer-guidelines.md
2026-04-02 16:13:26 -04:00
Teknium 798a7b99e4 docs: add Configuration Options section to Slack docs (#4644)
* docs: add Configuration Options section to Slack docs

Documents all config.yaml options for the Slack bot:
- Thread & reply behavior (reply_to_mode, reply_broadcast)
- Session isolation (group_sessions_per_user)
- Mention & trigger behavior (require_mention, mention_patterns, reply_prefix)
- Unauthorized user handling (unauthorized_dm_behavior)
- Voice transcription (stt_enabled)
- Full example config showing all options together

Includes a note about Slack's hardcoded @mention requirement in channels
(no free_response_channels equivalent like Discord/Telegram).

* docs: consolidate reply_in_thread into Configuration Options section

Folds the standalone Reply Threading subsection from PR #4643 into
the Thread & Reply Behavior subsection, keeping all config options
in one place. Adds reply_in_thread to the table and full example.
2026-04-02 12:38:13 -07:00
kshitijk4poor d2b08406a4 fix(agent): classify think-only empty responses before retrying 2026-04-02 12:29:18 -07:00
Teknium 241cbeeccd docs: add reply_in_thread config to Slack docs 2026-04-02 12:18:40 -07:00
Animesh Mishra b9a968c1de feat(slack): add reply_in_thread config option
By default, Hermes always threads replies to channel messages. Teams
that prefer direct channel replies had no way to opt out without
patching the source.

Add a reply_in_thread option (default: true) to the Slack platform
extra config:

  platforms:
    slack:
      extra:
        reply_in_thread: false

When false, _resolve_thread_ts() returns None for top-level channel
messages, so replies go directly to the channel. Messages already
inside an existing thread are still replied in-thread to preserve
conversation context. Default is true for full backward compatibility.
2026-04-02 12:18:40 -07:00
Teknium d89cc7fec1 feat(prompt): add Google model operational guidance for Gemini and Gemma (#4641)
Adapted from OpenCode's gemini.txt. Gemini and Gemma models now get
structured operational directives alongside tool-use enforcement:
absolute paths, verify-before-edit, dependency checks, conciseness,
parallel tool calls, non-interactive flags, autonomous execution.

Based on PR #4026, extended to cover Gemma models.
2026-04-02 11:52:34 -07:00
Teknium 3186668799 feat: per-turn primary runtime restoration and transport recovery (#4624)
Makes provider fallback turn-scoped in long-lived CLI sessions. Previously, a single transient failure pinned the session to the fallback provider for every subsequent turn.

- _primary_runtime dict snapshot at __init__ (model, provider, base_url, api_mode, client_kwargs, compressor state)
- _restore_primary_runtime() at top of run_conversation() — restores all state, resets fallback chain index
- _try_recover_primary_transport() — one extra recovery cycle (client rebuild + cooldown) for transient transport errors on direct endpoints before fallback
- Skipped for aggregator providers (OpenRouter, Nous)
- 25 tests

Inspired by #4612 (@betamod). Closes #4612.
2026-04-02 10:52:01 -07:00
Teknium 918d593544 chore: gitignore generated skills.json
Follow-up to #4500 — the extraction script generates this file at
build time, so it should not be committed.
2026-04-02 10:48:15 -07:00
Nacho Avecilla b8dd059c40 feat(website): add skills browse and search page to docs (#4500)
Adds a Skills Hub page to the documentation site with browsable/searchable catalog of all skills (built-in, optional, and community from cached hub indexes).

- Python extraction script (website/scripts/extract-skills.py) parses SKILL.md frontmatter and hub index caches into skills.json
- React page (website/src/pages/skills/) with search, category filtering, source filtering, and expandable skill cards
- CI workflow updated to run extraction before Docusaurus build
- Deploy trigger expanded to include skills/ and optional-skills/ changes

Authored by @IAvecilla
2026-04-02 10:47:38 -07:00
kshitijk4poor 20441cf2c8 fix(insights): persist token usage for non-CLI sessions 2026-04-02 10:47:13 -07:00
Teknium 585855d2ca fix: preserve Anthropic thinking block signatures across tool-use turns
Anthropic extended thinking blocks include an opaque 'signature' field
required for thinking chain continuity across multi-turn tool-use
conversations. Previously, normalize_anthropic_response() extracted
only the thinking text and set reasoning_details=None, discarding the
signature. On subsequent turns the API could not verify the chain.

Changes:
- _to_plain_data(): new recursive SDK-to-dict converter with depth cap
  (20 levels) and path-based cycle detection for safety
- _extract_preserved_thinking_blocks(): rehydrates preserved thinking
  blocks (including signature) from reasoning_details on assistant
  messages, placing them before tool_use blocks as Anthropic requires
- normalize_anthropic_response(): stores full thinking blocks in
  reasoning_details via _to_plain_data()
- _extract_reasoning(): adds 'thinking' key to the detail lookup chain
  so Anthropic-format details are found alongside OpenRouter format

Salvaged from PR #4503 by @priveperfumes — focused on the thinking
block continuity fix only (cache strategy and other changes excluded).
2026-04-02 10:30:32 -07:00
Teknium 28a073edc6 fix: repair OpenCode model routing and selection (#4508)
OpenCode Zen and Go are mixed-API-surface providers — different models
behind them use different API surfaces (GPT on Zen uses codex_responses,
Claude on Zen uses anthropic_messages, MiniMax on Go uses
anthropic_messages, GLM/Kimi on Go use chat_completions).

Changes:
- Add normalize_opencode_model_id() and opencode_model_api_mode() to
  models.py for model ID normalization and API surface routing
- Add _provider_supports_explicit_api_mode() to runtime_provider.py
  to prevent stale api_mode from leaking across provider switches
- Wire opencode routing into all three api_mode resolution paths:
  pool entry, api_key provider, and explicit runtime
- Add api_mode field to ModelSwitchResult for propagation through the
  switch pipeline
- Consolidate _PROVIDER_MODELS from main.py into models.py (single
  source of truth, eliminates duplicate dict)
- Add opencode normalization to setup wizard and model picker flows
- Add opencode block to _normalize_model_for_provider in CLI
- Add opencode-zen/go fallback model lists to setup.py

Tests: 160 targeted tests pass (26 new tests covering normalization,
api_mode routing per provider/model, persistence, and setup wizard
normalization).

Based on PR #3017 by SaM13997.

Co-authored-by: SaM13997 <139419381+SaM13997@users.noreply.github.com>
2026-04-02 09:36:24 -07:00
Devorun f4f64c413f fix(cli): ensure zero exit code on successful quiet mode queries (#4601) 2026-04-02 09:33:31 -07:00
Teknium 8dc5b11e95 fix(honcho): remove redundant local HOST import in _all_profile_host_configs
HOST is already imported at module level from honcho_integration.client.
The local import inside _all_profile_host_configs() was unnecessary.
2026-04-02 09:25:16 -07:00
Erosika 37d73d94bb fix: patch _local_config_path in tests for write isolation 2026-04-02 09:25:16 -07:00
Erosika a0eae33248 fix(honcho): address PR review findings
- Remove duplicate cmd_sync definition (kept version with error output)
- Fix from_env workspace to stay shared (hermes) not profile-derived
- Add docstring clarifying get_or_create is idempotent in status
- Remove unused import importlib in test
- Fix test assertion for shared workspace in from_env path
- Add 3 tests for sync_honcho_profiles_quiet
2026-04-02 09:25:16 -07:00
Erosika c146631e3b feat(honcho): sync command + auto-sync on hermes update
- hermes honcho sync: scan all profiles, create missing host blocks
- hermes update: automatically syncs Honcho config to all profiles
  after skill sync (existing users get profile mapping on next update)
- sync_honcho_profiles_quiet() for silent use from update path
2026-04-02 09:25:16 -07:00
Erosika 89eab74c67 feat(honcho): --target-profile flag + peer card display in status
- hermes honcho --target-profile <name> <command>: target another
  profile's Honcho config without switching profiles. Works with all
  subcommands (status, peer, mode, tokens, enable, disable, etc.)
- hermes honcho status now shows user peer card and AI peer
  representation when connected (fetched live from Honcho API)
2026-04-02 09:25:16 -07:00
Erosika 5f6bf2a473 fix(honcho): share workspace across profiles by default
Profiles inherit the default workspace instead of deriving a separate
one. All profiles see the same user context, sessions, and project
history. Each profile is a different AI peer in a shared space.

Workspace can still be overridden per-profile via config if isolation
is needed.
2026-04-02 09:25:16 -07:00
Erosika f27da5fe8e fix(honcho): remove linkedHosts from peers table 2026-04-02 09:25:16 -07:00
Erosika 0e90df1216 feat(honcho): eager peer creation + enable/disable per profile
- Eagerly create AI and user peers in Honcho when a profile is created
  (not deferred to first message). Uses idempotent peer() SDK call.
- hermes honcho enable: turn on Honcho for active profile, clone
  settings from default if first time, create peer immediately
- hermes honcho disable: turn off Honcho for active profile
- _ensure_peer_exists() helper for idempotent peer creation
2026-04-02 09:25:16 -07:00
Erosika 37458e72a2 feat(honcho): auto-clone config to new profiles on creation
When a profile is created and Honcho is already configured on the
default host, automatically creates a host block for the new profile
with inherited settings (memory mode, recall mode, write frequency,
peer name, etc.) and auto-derived workspace/aiPeer.

Zero-friction path: hermes profile create coder -> Honcho config
cloned as hermes.coder with all settings inherited.
2026-04-02 09:25:16 -07:00
Erosika d1189f2be9 feat(honcho): add cross-profile observability for Honcho integration
- hermes honcho status: shows active profile name + host key
- hermes honcho status --all: compact table of all profiles with mode,
  recall, write frequency per host block
- hermes honcho peers: cross-profile peer identity table (user peer,
  AI peer, linked hosts)
- All write commands (peer, mode, tokens) print [host_key] label when
  operating on a non-default profile
2026-04-02 09:25:16 -07:00
Erosika 18c156af8e feat(honcho): scope host and peer resolution to active Hermes profile
Derives the Honcho host key from the active Hermes profile so that each
profile gets its own Honcho host block, workspace, and AI peer identity.

Profile "coder" resolves to host "hermes.coder", reads from
hosts["hermes.coder"] in honcho.json, and defaults workspace + aiPeer
to the derived host name.

Resolution order: HERMES_HONCHO_HOST env var > active profile name >
"hermes" (default).

Complements #3681 (profiles) with the Honcho identity layer that was
part of #2845 (named instances), adapted to the merged profiles system.
2026-04-02 09:25:16 -07:00
Teknium 661a1b0ba2 fix: exclude matrix from [all] extras — python-olm is upstream-broken (#4615)
python-olm (required by matrix-nio[e2e]) fails to build on modern macOS:
- CMake 4 rejects vendored libolm's cmake_minimum_required(VERSION 3.4)
- Apple Clang 21+ rejects a C++ type error in include/olm/list.hh
- Upstream libolm repo is archived, no fix forthcoming

Including matrix in [all] causes the entire extras install to fail during
`hermes update`, silently dropping all other extras (telegram, discord,
slack, cron, etc.) when the fallback kicks in.

The [matrix] extra is preserved for opt-in install:
  pip install 'hermes-agent[matrix]'

Closes #4178
2026-04-02 09:21:37 -07:00
Teknium acea9ee20b fix(tests): fix 11 real test failures + major cascade poisoner (#4570)
Three root causes addressed:

1. AIAgent no longer defaults base_url to OpenRouter (9 tests)
   Tests that assert OpenRouter-specific behavior (prompt caching,
   reasoning extra_body, provider preferences) need explicit base_url
   and model set on the agent. Updated test_run_agent.py and
   test_provider_parity.py.

2. Credential pool auto-seeding from host env (2 tests)
   test_auxiliary_client.py tests for Anthropic OAuth and custom
   endpoint fallback were not mocking _select_pool_entry, so the
   host's credential pool interfered. Added pool + codex mocks.

3. sys.modules corruption cascade (major - ~250 tests)
   test_managed_modal_environment.py replaced sys.modules entries
   (tools, hermes_cli, agent packages) with SimpleNamespace stubs
   but had NO cleanup fixture. Every subsequent test in the process
   saw corrupted imports: 'cannot import get_config_path from
   <unknown module name>' and 'module tools has no attribute
   environments'. Added _restore_tool_and_agent_modules autouse
   fixture matching the pattern in test_managed_browserbase_and_modal.py.

   This was also the root cause of CI failures (104 failed on main).
2026-04-02 08:43:06 -07:00
Teknium 624ad582a5 fix: make gateway approval block agent thread like CLI does (#4557)
The gateway's dangerous command approval system was fundamentally broken:
the agent loop continued running after a command was flagged, and the
approval request only reached the user after the agent finished its
entire conversation loop. By then the context was lost.

This change makes the gateway approval mirror the CLI's synchronous
behavior. When a dangerous command is detected:

1. The agent thread blocks on a threading.Event
2. The approval request is sent to the user immediately
3. The user responds with /approve or /deny
4. The event is signaled and the agent resumes with the real result

The agent never sees 'approval_required' as a tool result. It either
gets the command output (approved) or a definitive BLOCKED message
(denied/timed out) — same as CLI mode.

Queue-based design supports multiple concurrent approvals (parallel
subagents via delegate_task, execute_code RPC handlers). Each approval
gets its own _ApprovalEntry with its own threading.Event. /approve
resolves the oldest (FIFO); /approve all resolves all at once.

Changes:
- tools/approval.py: Queue-based per-session blocking gateway approval
  (register/unregister callbacks, resolve with FIFO or all-at-once)
- gateway/run.py: Register approval callback in run_sync(), remove
  post-loop pop_pending hack, /approve and /deny support 'all' flag
- tests: 21 tests including parallel subagent E2E scenarios
2026-04-02 01:47:19 -07:00
Teknium 64584a931f cleanup: use _generate_session_key for parent key, fix trailing whitespace 2026-04-02 01:33:53 -07:00
Gary Chiu 8cb3596939 fix(gateway): seed DM thread sessions with parent transcript to preserve context 2026-04-02 01:33:53 -07:00
kshitijk4poor e94b4b2b40 fix: preserve allowed_users during setup reconfigure and quiet unconfigured provider warnings
Setup wizard now shows existing allowed_users when reconfiguring a
platform and preserves them if the user presses Enter. Previously the
wizard would display a misleading "No allowlist set" warning even when
the .env still held the original IDs.

Also downgrades the "provider X has no API key configured" log from
WARNING to DEBUG in resolve_provider_client — callers already handle
the None return with their own contextual messages. This eliminates
noisy startup warnings for providers in the fallback chain that the
user never configured (e.g. minimax).
2026-04-02 01:00:29 -07:00
Teknium 835defe074 fix: invalidate update cache for all profiles, not just current
hermes update only cleared .update_check for the active HERMES_HOME,
leaving other profiles showing stale 'N commits behind' in their banner.

Now _invalidate_update_cache() iterates over ~/.hermes/ (default) plus
every directory under ~/.hermes/profiles/ to clear all caches. The git
repo is shared across profiles so a single update brings them all current.

Reported by SteveSkedasticity on Discord.
2026-04-02 00:49:17 -07:00
Teknium e4db72ef39 fix: merge dotted+hyphenated FTS5 quoting into single pass
The original PR applied dotted and hyphenated regex quoting in two
sequential steps.  For terms with both dots and hyphens (e.g.
my-app.config.ts), step 2 would re-match inside already-quoted output,
producing malformed double-quoted FTS5 syntax.

Merged into a single regex pass: \w+(?:[.-]\w+)+ — handles dots,
hyphens, and mixed terms in one shot.  Added test coverage for the
mixed case.
2026-04-02 00:49:11 -07:00
Lume 9825cd7b1e fix(state): quote dotted terms in FTS5 queries
FTS5 queries containing dots (e.g. P2.2, simulate.p2.test.ts) can trigger query parse edge cases that yield OperationalError or empty results unless quoted. Extend _sanitize_fts5_query to wrap dotted tokens in double quotes (similar to hyphenated terms) and add regression tests.
2026-04-02 00:49:11 -07:00
Roland Parnaso c4e626b1fa refactor: extract _detect_file_drop() + add 28 tests
Extract the inline file-drop detection logic into a standalone
_detect_file_drop() function at module level for testability. The main
loop now calls this function instead of inlining the logic.

Tests cover:
- Slash commands still route correctly (/help, /quit, /xyz)
- Image paths auto-detected (.png, .jpg, .gif, etc.)
- Non-image files detected (.py, .txt, Makefile, etc.)
- Backslash-escaped spaces from macOS drag-and-drop
- Trailing user text preserved as remainder
- Edge cases: directories, symlinks, no-extension files
- Non-string input, empty strings, nonexistent paths
2026-04-02 00:40:27 -07:00
Roland Parnaso 1841886898 fix(cli): detect dragged file paths instead of treating them as slash commands
When a user drags a file into the terminal, macOS pastes the absolute
path (e.g. /Users/roland/Desktop/Screenshot.png) which starts with '/'
and was incorrectly routed to process_command(), producing an 'Unknown
command' error.

This change adds file-path detection before the slash-command check:
- Parses the first token, handling backslash-escaped spaces from macOS
- Checks if the path exists as a real file via Path.exists()
- Image files (.png, .jpg, etc.) are auto-attached to the message
- Non-image files are reformatted as [User attached file: ...] context
- Falls through to normal slash-command handling if not a real file path
2026-04-02 00:40:27 -07:00
Teknium f4bc6aa856 fix: scope extras retry to [all] group only
_load_installable_optional_extras() was returning ALL extras from
pyproject.toml except 'all', which included 'rl' and 'yc-bench' —
extras not referenced by [all] that install heavy research deps
(atroposlib, tinker, wandb) from git repos. Changed to parse the
[all] group's references and only retry those 18 extras.

Also moved tomllib import to function-level since it only runs
during the rare fallback path.
2026-04-02 00:40:07 -07:00
kshitijk4poor c91f4ef4ed fix(update): preserve optional extras during fallback install 2026-04-02 00:40:07 -07:00
Ben Barclay 5101f853ba Merge pull request #3287 from NousResearch/rewbs/tool-use-charge-to-subscription 2026-04-01 18:42:47 -07:00
Hermes Agent a0f5fc2570 fix(tools): add debug logging for token refresh and tighten domain check
- Add logger + debug log to read_nous_access_token() catch-all so token
  refresh failures are observable instead of silently swallowed
- Tighten _is_nous_auxiliary_client() domain check to use proper URL
  hostname parsing instead of substring match, preventing false-positives
  on domains like not-nousresearch.com or nousresearch.com.evil.com
2026-04-02 12:40:03 +11:00
Ben 647f99d4dd fix: resolve post-merge issues in auxiliary_client and model flow
- Add missing `from agent.credential_pool import load_pool` import to
  auxiliary_client.py (introduced by the credential pool feature in main)
- Thread `args` through `select_provider_and_model(args=None)` so TLS
  options from `cmd_model` reach `_model_flow_nous`
- Mock `_require_tty` in test_cmd_model_forwards_nous_login_tls_options
  so it can run in non-interactive test environments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 00:50:40 +00:00
Ben Barclay a2e56d044b Merge branch 'main' into rewbs/tool-use-charge-to-subscription 2026-04-02 11:00:35 +11:00
Robin Fernandes 1b7473e702 Fixes and refactors enabled by recent updates to main. 2026-03-31 09:29:59 +09:00
Robin Fernandes 1126284c97 Merge branch 'main' into rewbs/tool-use-charge-to-subscription 2026-03-31 09:29:43 +09:00
Robin Fernandes 6e4598ce1e Merge branch 'main' into rewbs/tool-use-charge-to-subscription 2026-03-31 08:48:54 +09:00
Robin Fernandes 1cbb1b99cc Gate tool-gateway behind an env var, so it's not in users' faces until we're ready. Even if users enable it, it'll be blocked server-side for now, until we unlock for non-admin users on tool-gateway. 2026-03-30 13:28:10 +09:00
Robin Fernandes e95965d76a Merge branch 'main' into rewbs/tool-use-charge-to-subscription 2026-03-26 16:18:28 -07:00
Robin Fernandes 95dc9aaa75 feat: add managed tool gateway and Nous subscription support
- add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support
2026-03-26 16:17:58 -07:00
519 changed files with 86406 additions and 9582 deletions
+10
View File
@@ -14,6 +14,16 @@
# LLM_MODEL is no longer read from .env — this line is kept for reference only.
# LLM_MODEL=anthropic/claude-opus-4.6
# =============================================================================
# LLM PROVIDER (Google AI Studio / Gemini)
# =============================================================================
# Native Gemini API via Google's OpenAI-compatible endpoint.
# Get your key at: https://aistudio.google.com/app/apikey
# GOOGLE_API_KEY=your_google_ai_studio_key_here
# GEMINI_API_KEY=your_gemini_key_here # alias for GOOGLE_API_KEY
# Optional base URL override (default: Google's OpenAI-compatible endpoint)
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
# =============================================================================
# LLM PROVIDER (z.ai / GLM)
# =============================================================================
+12
View File
@@ -6,6 +6,8 @@ on:
paths:
- 'website/**'
- 'landingpage/**'
- 'skills/**'
- 'optional-skills/**'
- '.github/workflows/deploy-site.yml'
workflow_dispatch:
@@ -34,6 +36,16 @@ jobs:
cache: npm
cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PyYAML for skill extraction
run: pip install pyyaml
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Install dependencies
run: npm ci
working-directory: website
+5 -2
View File
@@ -27,8 +27,11 @@ jobs:
with:
python-version: '3.11'
- name: Install ascii-guard
run: python -m pip install ascii-guard
- name: Install Python dependencies
run: python -m pip install ascii-guard pyyaml
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
- name: Lint docs diagrams
run: npm run lint:diagrams
+290
View File
@@ -0,0 +1,290 @@
# Hermes Agent v0.7.0 (v2026.4.3)
**Release Date:** April 3, 2026
> The resilience release — pluggable memory providers, credential pool rotation, Camofox anti-detection browser, inline diff previews, gateway hardening across race conditions and approval routing, and deep security fixes across 168 PRs and 46 resolved issues.
---
## ✨ Highlights
- **Pluggable Memory Provider Interface** — Memory is now an extensible plugin system. Third-party memory backends (Honcho, vector stores, custom DBs) implement a simple provider ABC and register via the plugin system. Built-in memory is the default provider. Honcho integration restored to full parity as the reference plugin with profile-scoped host/peer resolution. ([#4623](https://github.com/NousResearch/hermes-agent/pull/4623), [#4616](https://github.com/NousResearch/hermes-agent/pull/4616), [#4355](https://github.com/NousResearch/hermes-agent/pull/4355))
- **Same-Provider Credential Pools** — Configure multiple API keys for the same provider with automatic rotation. Thread-safe `least_used` strategy distributes load across keys, and 401 failures trigger automatic rotation to the next credential. Set up via the setup wizard or `credential_pool` config. ([#4188](https://github.com/NousResearch/hermes-agent/pull/4188), [#4300](https://github.com/NousResearch/hermes-agent/pull/4300), [#4361](https://github.com/NousResearch/hermes-agent/pull/4361))
- **Camofox Anti-Detection Browser Backend** — New local browser backend using Camoufox for stealth browsing. Persistent sessions with VNC URL discovery for visual debugging, configurable SSRF bypass for local backends, auto-install via `hermes tools`. ([#4008](https://github.com/NousResearch/hermes-agent/pull/4008), [#4419](https://github.com/NousResearch/hermes-agent/pull/4419), [#4292](https://github.com/NousResearch/hermes-agent/pull/4292))
- **Inline Diff Previews** — File write and patch operations now show inline diffs in the tool activity feed, giving you visual confirmation of what changed before the agent moves on. ([#4411](https://github.com/NousResearch/hermes-agent/pull/4411), [#4423](https://github.com/NousResearch/hermes-agent/pull/4423))
- **API Server Session Continuity & Tool Streaming** — The API server (Open WebUI integration) now streams tool progress events in real-time and supports `X-Hermes-Session-Id` headers for persistent sessions across requests. Sessions persist to the shared SessionDB. ([#4092](https://github.com/NousResearch/hermes-agent/pull/4092), [#4478](https://github.com/NousResearch/hermes-agent/pull/4478), [#4802](https://github.com/NousResearch/hermes-agent/pull/4802))
- **ACP: Client-Provided MCP Servers** — Editor integrations (VS Code, Zed, JetBrains) can now register their own MCP servers, which Hermes picks up as additional agent tools. Your editor's MCP ecosystem flows directly into the agent. ([#4705](https://github.com/NousResearch/hermes-agent/pull/4705))
- **Gateway Hardening** — Major stability pass across race conditions, photo media delivery, flood control, stuck sessions, approval routing, and compression death spirals. The gateway is substantially more reliable in production. ([#4727](https://github.com/NousResearch/hermes-agent/pull/4727), [#4750](https://github.com/NousResearch/hermes-agent/pull/4750), [#4798](https://github.com/NousResearch/hermes-agent/pull/4798), [#4557](https://github.com/NousResearch/hermes-agent/pull/4557))
- **Security: Secret Exfiltration Blocking** — Browser URLs and LLM responses are now scanned for secret patterns, blocking exfiltration attempts via URL encoding, base64, or prompt injection. Credential directory protections expanded to `.docker`, `.azure`, `.config/gh`. Execute_code sandbox output is redacted. ([#4483](https://github.com/NousResearch/hermes-agent/pull/4483), [#4360](https://github.com/NousResearch/hermes-agent/pull/4360), [#4305](https://github.com/NousResearch/hermes-agent/pull/4305), [#4327](https://github.com/NousResearch/hermes-agent/pull/4327))
---
## 🏗️ Core Agent & Architecture
### Provider & Model Support
- **Same-provider credential pools** — configure multiple API keys with automatic `least_used` rotation and 401 failover ([#4188](https://github.com/NousResearch/hermes-agent/pull/4188), [#4300](https://github.com/NousResearch/hermes-agent/pull/4300))
- **Credential pool preserved through smart routing** — pool state survives fallback provider switches and defers eager fallback on 429 ([#4361](https://github.com/NousResearch/hermes-agent/pull/4361))
- **Per-turn primary runtime restoration** — after fallback provider use, the agent automatically restores the primary provider on the next turn with transport recovery ([#4624](https://github.com/NousResearch/hermes-agent/pull/4624))
- **`developer` role for GPT-5 and Codex models** — uses OpenAI's recommended system message role for newer models ([#4498](https://github.com/NousResearch/hermes-agent/pull/4498))
- **Google model operational guidance** — Gemini and Gemma models get provider-specific prompting guidance ([#4641](https://github.com/NousResearch/hermes-agent/pull/4641))
- **Anthropic long-context tier 429 handling** — automatically reduces context to 200k when hitting tier limits ([#4747](https://github.com/NousResearch/hermes-agent/pull/4747))
- **URL-based auth for third-party Anthropic endpoints** + CI test fixes ([#4148](https://github.com/NousResearch/hermes-agent/pull/4148))
- **Bearer auth for MiniMax Anthropic endpoints** ([#4028](https://github.com/NousResearch/hermes-agent/pull/4028))
- **Fireworks context length detection** ([#4158](https://github.com/NousResearch/hermes-agent/pull/4158))
- **Standard DashScope international endpoint** for Alibaba provider ([#4133](https://github.com/NousResearch/hermes-agent/pull/4133), closes [#3912](https://github.com/NousResearch/hermes-agent/issues/3912))
- **Custom providers context_length** honored in hygiene compression ([#4085](https://github.com/NousResearch/hermes-agent/pull/4085))
- **Non-sk-ant keys** treated as regular API keys, not OAuth tokens ([#4093](https://github.com/NousResearch/hermes-agent/pull/4093))
- **Claude-sonnet-4.6** added to OpenRouter and Nous model lists ([#4157](https://github.com/NousResearch/hermes-agent/pull/4157))
- **Qwen 3.6 Plus Preview** added to model lists ([#4376](https://github.com/NousResearch/hermes-agent/pull/4376))
- **MiniMax M2.7** added to hermes model picker and OpenCode ([#4208](https://github.com/NousResearch/hermes-agent/pull/4208))
- **Auto-detect models from server probe** in custom endpoint setup ([#4218](https://github.com/NousResearch/hermes-agent/pull/4218))
- **Config.yaml single source of truth** for endpoint URLs — no more env var vs config.yaml conflicts ([#4165](https://github.com/NousResearch/hermes-agent/pull/4165))
- **Setup wizard no longer overwrites** custom endpoint config ([#4180](https://github.com/NousResearch/hermes-agent/pull/4180), closes [#4172](https://github.com/NousResearch/hermes-agent/issues/4172))
- **Unified setup wizard provider selection** with `hermes model` — single code path for both flows ([#4200](https://github.com/NousResearch/hermes-agent/pull/4200))
- **Root-level provider config** no longer overrides `model.provider` ([#4329](https://github.com/NousResearch/hermes-agent/pull/4329))
- **Rate-limit pairing rejection messages** to prevent spam ([#4081](https://github.com/NousResearch/hermes-agent/pull/4081))
### Agent Loop & Conversation
- **Preserve Anthropic thinking block signatures** across tool-use turns ([#4626](https://github.com/NousResearch/hermes-agent/pull/4626))
- **Classify think-only empty responses** before retrying — prevents infinite retry loops on models that produce thinking blocks without content ([#4645](https://github.com/NousResearch/hermes-agent/pull/4645))
- **Prevent compression death spiral** from API disconnects — stops the loop where compression triggers, fails, compresses again ([#4750](https://github.com/NousResearch/hermes-agent/pull/4750), closes [#2153](https://github.com/NousResearch/hermes-agent/issues/2153))
- **Persist compressed context** to gateway session after mid-run compression ([#4095](https://github.com/NousResearch/hermes-agent/pull/4095))
- **Context-exceeded error messages** now include actionable guidance ([#4155](https://github.com/NousResearch/hermes-agent/pull/4155), closes [#4061](https://github.com/NousResearch/hermes-agent/issues/4061))
- **Strip orphaned think/reasoning tags** from user-facing responses ([#4311](https://github.com/NousResearch/hermes-agent/pull/4311), closes [#4285](https://github.com/NousResearch/hermes-agent/issues/4285))
- **Harden Codex responses preflight** and stream error handling ([#4313](https://github.com/NousResearch/hermes-agent/pull/4313))
- **Deterministic call_id fallbacks** instead of random UUIDs for prompt cache consistency ([#3991](https://github.com/NousResearch/hermes-agent/pull/3991))
- **Context pressure warning spam** prevented after compression ([#4012](https://github.com/NousResearch/hermes-agent/pull/4012))
- **AsyncOpenAI created lazily** in trajectory compressor to avoid closed event loop errors ([#4013](https://github.com/NousResearch/hermes-agent/pull/4013))
### Memory & Sessions
- **Pluggable memory provider interface** — ABC-based plugin system for custom memory backends with profile isolation ([#4623](https://github.com/NousResearch/hermes-agent/pull/4623))
- **Honcho full integration parity** restored as reference memory provider plugin ([#4355](https://github.com/NousResearch/hermes-agent/pull/4355)) — @erosika
- **Honcho profile-scoped** host and peer resolution ([#4616](https://github.com/NousResearch/hermes-agent/pull/4616))
- **Memory flush state persisted** to prevent redundant re-flushes on gateway restart ([#4481](https://github.com/NousResearch/hermes-agent/pull/4481))
- **Memory provider tools** routed through sequential execution path ([#4803](https://github.com/NousResearch/hermes-agent/pull/4803))
- **Honcho config** written to instance-local path for profile isolation ([#4037](https://github.com/NousResearch/hermes-agent/pull/4037))
- **API server sessions** persist to shared SessionDB ([#4802](https://github.com/NousResearch/hermes-agent/pull/4802))
- **Token usage persisted** for non-CLI sessions ([#4627](https://github.com/NousResearch/hermes-agent/pull/4627))
- **Quote dotted terms in FTS5 queries** — fixes session search for terms containing dots ([#4549](https://github.com/NousResearch/hermes-agent/pull/4549))
---
## 📱 Messaging Platforms (Gateway)
### Gateway Core
- **Race condition fixes** — photo media loss, flood control, stuck sessions, and STT config issues resolved in one hardening pass ([#4727](https://github.com/NousResearch/hermes-agent/pull/4727))
- **Approval routing through running-agent guard** — `/approve` and `/deny` now route correctly when the agent is blocked waiting for approval instead of being swallowed as interrupts ([#4798](https://github.com/NousResearch/hermes-agent/pull/4798), [#4557](https://github.com/NousResearch/hermes-agent/pull/4557), closes [#4542](https://github.com/NousResearch/hermes-agent/issues/4542))
- **Resume agent after /approve** — tool result is no longer lost when executing blocked commands ([#4418](https://github.com/NousResearch/hermes-agent/pull/4418))
- **DM thread sessions seeded** with parent transcript to preserve context ([#4559](https://github.com/NousResearch/hermes-agent/pull/4559))
- **Skill-aware slash commands** — gateway dynamically registers installed skills as slash commands with paginated `/commands` list and Telegram 100-command cap ([#3934](https://github.com/NousResearch/hermes-agent/pull/3934), [#4005](https://github.com/NousResearch/hermes-agent/pull/4005), [#4006](https://github.com/NousResearch/hermes-agent/pull/4006), [#4010](https://github.com/NousResearch/hermes-agent/pull/4010), [#4023](https://github.com/NousResearch/hermes-agent/pull/4023))
- **Per-platform disabled skills** respected in Telegram menu and gateway dispatch ([#4799](https://github.com/NousResearch/hermes-agent/pull/4799))
- **Remove user-facing compression warnings** — cleaner message flow ([#4139](https://github.com/NousResearch/hermes-agent/pull/4139))
- **`-v/-q` flags wired to stderr logging** for gateway service ([#4474](https://github.com/NousResearch/hermes-agent/pull/4474))
- **HERMES_HOME remapped** to target user in system service unit ([#4456](https://github.com/NousResearch/hermes-agent/pull/4456))
- **Honor default for invalid bool-like config values** ([#4029](https://github.com/NousResearch/hermes-agent/pull/4029))
- **setsid instead of systemd-run** for `/update` command to avoid systemd permission issues ([#4104](https://github.com/NousResearch/hermes-agent/pull/4104), closes [#4017](https://github.com/NousResearch/hermes-agent/issues/4017))
- **'Initializing agent...'** shown on first message for better UX ([#4086](https://github.com/NousResearch/hermes-agent/pull/4086))
- **Allow running gateway service as root** for LXC/container environments ([#4732](https://github.com/NousResearch/hermes-agent/pull/4732))
### Telegram
- **32-char limit on command names** with collision avoidance ([#4211](https://github.com/NousResearch/hermes-agent/pull/4211))
- **Priority order enforced** in menu — core > plugins > skills ([#4023](https://github.com/NousResearch/hermes-agent/pull/4023))
- **Capped at 50 commands** — API rejects above ~60 ([#4006](https://github.com/NousResearch/hermes-agent/pull/4006))
- **Skip empty/whitespace text** to prevent 400 errors ([#4388](https://github.com/NousResearch/hermes-agent/pull/4388))
- **E2E gateway tests** added ([#4497](https://github.com/NousResearch/hermes-agent/pull/4497)) — @pefontana
### Discord
- **Button-based approval UI** — register `/approve` and `/deny` slash commands with interactive button prompts ([#4800](https://github.com/NousResearch/hermes-agent/pull/4800))
- **Configurable reactions** — `discord.reactions` config option to disable message processing reactions ([#4199](https://github.com/NousResearch/hermes-agent/pull/4199))
- **Skip reactions and auto-threading** for unauthorized users ([#4387](https://github.com/NousResearch/hermes-agent/pull/4387))
### Slack
- **Reply in thread** — `slack.reply_in_thread` config option for threaded responses ([#4643](https://github.com/NousResearch/hermes-agent/pull/4643), closes [#2662](https://github.com/NousResearch/hermes-agent/issues/2662))
### WhatsApp
- **Enforce require_mention in group chats** ([#4730](https://github.com/NousResearch/hermes-agent/pull/4730))
### Webhook
- **Platform support fixes** — skip home channel prompt, disable tool progress for webhook adapters ([#4660](https://github.com/NousResearch/hermes-agent/pull/4660))
### Matrix
- **E2EE decryption hardening** — request missing keys, auto-trust devices, retry buffered events ([#4083](https://github.com/NousResearch/hermes-agent/pull/4083))
---
## 🖥️ CLI & User Experience
### New Slash Commands
- **`/yolo`** — toggle dangerous command approvals on/off for the session ([#3990](https://github.com/NousResearch/hermes-agent/pull/3990))
- **`/btw`** — ephemeral side questions that don't affect the main conversation context ([#4161](https://github.com/NousResearch/hermes-agent/pull/4161))
- **`/profile`** — show active profile info without leaving the chat session ([#4027](https://github.com/NousResearch/hermes-agent/pull/4027))
### Interactive CLI
- **Inline diff previews** for write and patch operations in the tool activity feed ([#4411](https://github.com/NousResearch/hermes-agent/pull/4411), [#4423](https://github.com/NousResearch/hermes-agent/pull/4423))
- **TUI pinned to bottom** on startup — no more large blank spaces between response and input ([#4412](https://github.com/NousResearch/hermes-agent/pull/4412), [#4359](https://github.com/NousResearch/hermes-agent/pull/4359), closes [#4398](https://github.com/NousResearch/hermes-agent/issues/4398), [#4421](https://github.com/NousResearch/hermes-agent/issues/4421))
- **`/history` and `/resume`** now surface recent sessions directly instead of requiring search ([#4728](https://github.com/NousResearch/hermes-agent/pull/4728))
- **Cache tokens shown** in `/insights` overview so total adds up ([#4428](https://github.com/NousResearch/hermes-agent/pull/4428))
- **`--max-turns` CLI flag** for `hermes chat` to limit agent iterations ([#4314](https://github.com/NousResearch/hermes-agent/pull/4314))
- **Detect dragged file paths** instead of treating them as slash commands ([#4533](https://github.com/NousResearch/hermes-agent/pull/4533)) — @rolme
- **Allow empty strings and falsy values** in `config set` ([#4310](https://github.com/NousResearch/hermes-agent/pull/4310), closes [#4277](https://github.com/NousResearch/hermes-agent/issues/4277))
- **Voice mode in WSL** when PulseAudio bridge is configured ([#4317](https://github.com/NousResearch/hermes-agent/pull/4317))
- **Respect `NO_COLOR` env var** and `TERM=dumb` for accessibility ([#4079](https://github.com/NousResearch/hermes-agent/pull/4079), closes [#4066](https://github.com/NousResearch/hermes-agent/issues/4066)) — @SHL0MS
- **Correct shell reload instruction** for macOS/zsh users ([#4025](https://github.com/NousResearch/hermes-agent/pull/4025))
- **Zero exit code** on successful quiet mode queries ([#4613](https://github.com/NousResearch/hermes-agent/pull/4613), closes [#4601](https://github.com/NousResearch/hermes-agent/issues/4601)) — @devorun
- **on_session_end hook fires** on interrupted exits ([#4159](https://github.com/NousResearch/hermes-agent/pull/4159))
- **Profile list display** reads `model.default` key correctly ([#4160](https://github.com/NousResearch/hermes-agent/pull/4160))
- **Browser and TTS** shown in reconfigure menu ([#4041](https://github.com/NousResearch/hermes-agent/pull/4041))
- **Web backend priority** detection simplified ([#4036](https://github.com/NousResearch/hermes-agent/pull/4036))
### Setup & Configuration
- **Allowed_users preserved** during setup and quiet unconfigured provider warnings ([#4551](https://github.com/NousResearch/hermes-agent/pull/4551)) — @kshitijk4poor
- **Save API key to model config** for custom endpoints ([#4202](https://github.com/NousResearch/hermes-agent/pull/4202), closes [#4182](https://github.com/NousResearch/hermes-agent/issues/4182))
- **Claude Code credentials gated** behind explicit Hermes config in wizard trigger ([#4210](https://github.com/NousResearch/hermes-agent/pull/4210))
- **Atomic writes in save_config_value** to prevent config loss on interrupt ([#4298](https://github.com/NousResearch/hermes-agent/pull/4298), [#4320](https://github.com/NousResearch/hermes-agent/pull/4320))
- **Scopes field written** to Claude Code credentials on token refresh ([#4126](https://github.com/NousResearch/hermes-agent/pull/4126))
### Update System
- **Fork detection and upstream sync** in `hermes update` ([#4744](https://github.com/NousResearch/hermes-agent/pull/4744))
- **Preserve working optional extras** when one extra fails during update ([#4550](https://github.com/NousResearch/hermes-agent/pull/4550))
- **Handle conflicted git index** during hermes update ([#4735](https://github.com/NousResearch/hermes-agent/pull/4735))
- **Avoid launchd restart race** on macOS ([#4736](https://github.com/NousResearch/hermes-agent/pull/4736))
- **Missing subprocess.run() timeouts** added to doctor and status commands ([#4009](https://github.com/NousResearch/hermes-agent/pull/4009))
---
## 🔧 Tool System
### Browser
- **Camofox anti-detection browser backend** — local stealth browsing with auto-install via `hermes tools` ([#4008](https://github.com/NousResearch/hermes-agent/pull/4008))
- **Persistent Camofox sessions** with VNC URL discovery for visual debugging ([#4419](https://github.com/NousResearch/hermes-agent/pull/4419))
- **Skip SSRF check for local backends** (Camofox, headless Chromium) ([#4292](https://github.com/NousResearch/hermes-agent/pull/4292))
- **Configurable SSRF check** via `browser.allow_private_urls` ([#4198](https://github.com/NousResearch/hermes-agent/pull/4198)) — @nils010485
- **CAMOFOX_PORT=9377** added to Docker commands ([#4340](https://github.com/NousResearch/hermes-agent/pull/4340))
### File Operations
- **Inline diff previews** on write and patch actions ([#4411](https://github.com/NousResearch/hermes-agent/pull/4411), [#4423](https://github.com/NousResearch/hermes-agent/pull/4423))
- **Stale file detection** on write and patch — warns when file was modified externally since last read ([#4345](https://github.com/NousResearch/hermes-agent/pull/4345))
- **Staleness timestamp refreshed** after writes ([#4390](https://github.com/NousResearch/hermes-agent/pull/4390))
- **Size guard, dedup, and device blocking** on read_file ([#4315](https://github.com/NousResearch/hermes-agent/pull/4315))
### MCP
- **Stability fix pack** — reload timeout, shutdown cleanup, event loop handler, OAuth non-blocking ([#4757](https://github.com/NousResearch/hermes-agent/pull/4757), closes [#4462](https://github.com/NousResearch/hermes-agent/issues/4462), [#2537](https://github.com/NousResearch/hermes-agent/issues/2537))
### ACP (Editor Integration)
- **Client-provided MCP servers** registered as agent tools — editors pass their MCP servers to Hermes ([#4705](https://github.com/NousResearch/hermes-agent/pull/4705))
### Skills System
- **Size limits for agent writes** and **fuzzy matching for skill patch** — prevents oversized skill writes and improves edit reliability ([#4414](https://github.com/NousResearch/hermes-agent/pull/4414))
- **Validate hub bundle paths** before install — blocks path traversal in skill bundles ([#3986](https://github.com/NousResearch/hermes-agent/pull/3986))
- **Unified hermes-agent and hermes-agent-setup** into single skill ([#4332](https://github.com/NousResearch/hermes-agent/pull/4332))
- **Skill metadata type check** in extract_skill_conditions ([#4479](https://github.com/NousResearch/hermes-agent/pull/4479))
### New/Updated Skills
- **research-paper-writing** — full end-to-end research pipeline (replaced ml-paper-writing) ([#4654](https://github.com/NousResearch/hermes-agent/pull/4654)) — @SHL0MS
- **ascii-video** — text readability techniques and external layout oracle ([#4054](https://github.com/NousResearch/hermes-agent/pull/4054)) — @SHL0MS
- **youtube-transcript** updated for youtube-transcript-api v1.x ([#4455](https://github.com/NousResearch/hermes-agent/pull/4455)) — @el-analista
- **Skills browse and search page** added to documentation site ([#4500](https://github.com/NousResearch/hermes-agent/pull/4500)) — @IAvecilla
---
## 🔒 Security & Reliability
### Security Hardening
- **Block secret exfiltration** via browser URLs and LLM responses — scans for secret patterns in URL encoding, base64, and prompt injection vectors ([#4483](https://github.com/NousResearch/hermes-agent/pull/4483))
- **Redact secrets from execute_code sandbox output** ([#4360](https://github.com/NousResearch/hermes-agent/pull/4360))
- **Protect `.docker`, `.azure`, `.config/gh` credential directories** from read/write via file tools and terminal ([#4305](https://github.com/NousResearch/hermes-agent/pull/4305), [#4327](https://github.com/NousResearch/hermes-agent/pull/4327)) — @memosr
- **GitHub OAuth token patterns** added to redaction + snapshot redact flag ([#4295](https://github.com/NousResearch/hermes-agent/pull/4295))
- **Reject private and loopback IPs** in Telegram DoH fallback ([#4129](https://github.com/NousResearch/hermes-agent/pull/4129))
- **Reject path traversal** in credential file registration ([#4316](https://github.com/NousResearch/hermes-agent/pull/4316))
- **Validate tar archive member paths** on profile import — blocks zip-slip attacks ([#4318](https://github.com/NousResearch/hermes-agent/pull/4318))
- **Exclude auth.json and .env** from profile exports ([#4475](https://github.com/NousResearch/hermes-agent/pull/4475))
### Reliability
- **Prevent compression death spiral** from API disconnects ([#4750](https://github.com/NousResearch/hermes-agent/pull/4750), closes [#2153](https://github.com/NousResearch/hermes-agent/issues/2153))
- **Handle `is_closed` as method** in OpenAI SDK — prevents false positive client closure detection ([#4416](https://github.com/NousResearch/hermes-agent/pull/4416), closes [#4377](https://github.com/NousResearch/hermes-agent/issues/4377))
- **Exclude matrix from [all] extras** — python-olm is upstream-broken, prevents install failures ([#4615](https://github.com/NousResearch/hermes-agent/pull/4615), closes [#4178](https://github.com/NousResearch/hermes-agent/issues/4178))
- **OpenCode model routing** repaired ([#4508](https://github.com/NousResearch/hermes-agent/pull/4508))
- **Docker container image** optimized ([#4034](https://github.com/NousResearch/hermes-agent/pull/4034)) — @bcross
### Windows & Cross-Platform
- **Voice mode in WSL** with PulseAudio bridge ([#4317](https://github.com/NousResearch/hermes-agent/pull/4317))
- **Homebrew packaging** preparation ([#4099](https://github.com/NousResearch/hermes-agent/pull/4099))
- **CI fork conditionals** to prevent workflow failures on forks ([#4107](https://github.com/NousResearch/hermes-agent/pull/4107))
---
## 🐛 Notable Bug Fixes
- **Gateway approval blocked agent thread** — approval now blocks the agent thread like CLI does, preventing tool result loss ([#4557](https://github.com/NousResearch/hermes-agent/pull/4557), closes [#4542](https://github.com/NousResearch/hermes-agent/issues/4542))
- **Compression death spiral** from API disconnects — detected and halted instead of looping ([#4750](https://github.com/NousResearch/hermes-agent/pull/4750), closes [#2153](https://github.com/NousResearch/hermes-agent/issues/2153))
- **Anthropic thinking blocks lost** across tool-use turns ([#4626](https://github.com/NousResearch/hermes-agent/pull/4626))
- **Profile model config ignored** with `-p` flag — model.model now promoted to model.default correctly ([#4160](https://github.com/NousResearch/hermes-agent/pull/4160), closes [#4486](https://github.com/NousResearch/hermes-agent/issues/4486))
- **CLI blank space** between response and input area ([#4412](https://github.com/NousResearch/hermes-agent/pull/4412), [#4359](https://github.com/NousResearch/hermes-agent/pull/4359), closes [#4398](https://github.com/NousResearch/hermes-agent/issues/4398))
- **Dragged file paths** treated as slash commands instead of file references ([#4533](https://github.com/NousResearch/hermes-agent/pull/4533)) — @rolme
- **Orphaned `</think>` tags** leaking into user-facing responses ([#4311](https://github.com/NousResearch/hermes-agent/pull/4311), closes [#4285](https://github.com/NousResearch/hermes-agent/issues/4285))
- **OpenAI SDK `is_closed`** is a method not property — false positive client closure ([#4416](https://github.com/NousResearch/hermes-agent/pull/4416), closes [#4377](https://github.com/NousResearch/hermes-agent/issues/4377))
- **MCP OAuth server** could block Hermes startup instead of degrading gracefully ([#4757](https://github.com/NousResearch/hermes-agent/pull/4757), closes [#4462](https://github.com/NousResearch/hermes-agent/issues/4462))
- **MCP event loop closed** on shutdown with HTTP servers ([#4757](https://github.com/NousResearch/hermes-agent/pull/4757), closes [#2537](https://github.com/NousResearch/hermes-agent/issues/2537))
- **Alibaba provider** hardcoded to wrong endpoint ([#4133](https://github.com/NousResearch/hermes-agent/pull/4133), closes [#3912](https://github.com/NousResearch/hermes-agent/issues/3912))
- **Slack reply_in_thread** missing config option ([#4643](https://github.com/NousResearch/hermes-agent/pull/4643), closes [#2662](https://github.com/NousResearch/hermes-agent/issues/2662))
- **Quiet mode exit code** — successful `-q` queries no longer exit nonzero ([#4613](https://github.com/NousResearch/hermes-agent/pull/4613), closes [#4601](https://github.com/NousResearch/hermes-agent/issues/4601))
- **Mobile sidebar** shows only close button due to backdrop-filter issue in docs site ([#4207](https://github.com/NousResearch/hermes-agent/pull/4207)) — @xsmyile
- **Config restore reverted** by stale-branch squash merge — `_config_version` fixed ([#4440](https://github.com/NousResearch/hermes-agent/pull/4440))
---
## 🧪 Testing
- **Telegram gateway E2E tests** — full integration test suite for the Telegram adapter ([#4497](https://github.com/NousResearch/hermes-agent/pull/4497)) — @pefontana
- **11 real test failures fixed** plus sys.modules cascade poisoner resolved ([#4570](https://github.com/NousResearch/hermes-agent/pull/4570))
- **7 CI failures resolved** across hooks, plugins, and skill tests ([#3936](https://github.com/NousResearch/hermes-agent/pull/3936))
- **Codex 401 refresh tests** updated for CI compatibility ([#4166](https://github.com/NousResearch/hermes-agent/pull/4166))
- **Stale OPENAI_BASE_URL test** fixed ([#4217](https://github.com/NousResearch/hermes-agent/pull/4217))
---
## 📚 Documentation
- **Comprehensive documentation audit** — 9 HIGH and 20+ MEDIUM gaps fixed across 21 files ([#4087](https://github.com/NousResearch/hermes-agent/pull/4087))
- **Site navigation restructured** — features and platforms promoted to top-level ([#4116](https://github.com/NousResearch/hermes-agent/pull/4116))
- **Tool progress streaming** documented for API server and Open WebUI ([#4138](https://github.com/NousResearch/hermes-agent/pull/4138))
- **Telegram webhook mode** documentation ([#4089](https://github.com/NousResearch/hermes-agent/pull/4089))
- **Local LLM provider guides** — comprehensive setup guides with context length warnings ([#4294](https://github.com/NousResearch/hermes-agent/pull/4294))
- **WhatsApp allowlist behavior** clarified with `WHATSAPP_ALLOW_ALL_USERS` documentation ([#4293](https://github.com/NousResearch/hermes-agent/pull/4293))
- **Slack configuration options** — new config section in Slack docs ([#4644](https://github.com/NousResearch/hermes-agent/pull/4644))
- **Terminal backends section** expanded + docs build fixes ([#4016](https://github.com/NousResearch/hermes-agent/pull/4016))
- **Adding-providers guide** updated for unified setup flow ([#4201](https://github.com/NousResearch/hermes-agent/pull/4201))
- **ACP Zed config** fixed ([#4743](https://github.com/NousResearch/hermes-agent/pull/4743))
- **Community FAQ** entries for common workflows and troubleshooting ([#4797](https://github.com/NousResearch/hermes-agent/pull/4797))
- **Skills browse and search page** on docs site ([#4500](https://github.com/NousResearch/hermes-agent/pull/4500)) — @IAvecilla
---
## 👥 Contributors
### Core
- **@teknium1** — 135 commits across all subsystems
### Top Community Contributors
- **@kshitijk4poor** — 13 commits: preserve allowed_users during setup ([#4551](https://github.com/NousResearch/hermes-agent/pull/4551)), and various fixes
- **@erosika** — 12 commits: Honcho full integration parity restored as memory provider plugin ([#4355](https://github.com/NousResearch/hermes-agent/pull/4355))
- **@pefontana** — 9 commits: Telegram gateway E2E test suite ([#4497](https://github.com/NousResearch/hermes-agent/pull/4497))
- **@bcross** — 5 commits: Docker container image optimization ([#4034](https://github.com/NousResearch/hermes-agent/pull/4034))
- **@SHL0MS** — 4 commits: NO_COLOR/TERM=dumb support ([#4079](https://github.com/NousResearch/hermes-agent/pull/4079)), ascii-video skill updates ([#4054](https://github.com/NousResearch/hermes-agent/pull/4054)), research-paper-writing skill ([#4654](https://github.com/NousResearch/hermes-agent/pull/4654))
### All Contributors
@0xbyt4, @arasovic, @Bartok9, @bcross, @binhnt92, @camden-lowrance, @curtitoo, @Dakota, @Dave Tist, @Dean Kerr, @devorun, @dieutx, @Dilee, @el-analista, @erosika, @Gutslabs, @IAvecilla, @Jack, @Johannnnn506, @kshitijk4poor, @Laura Batalha, @Leegenux, @Lume, @MacroAnarchy, @maymuneth, @memosr, @NexVeridian, @Nick, @nils010485, @pefontana, @Penov, @rolme, @SHL0MS, @txchen, @xsmyile
### Issues Resolved from Community
@acsezen ([#2537](https://github.com/NousResearch/hermes-agent/issues/2537)), @arasovic ([#4285](https://github.com/NousResearch/hermes-agent/issues/4285)), @camden-lowrance ([#4462](https://github.com/NousResearch/hermes-agent/issues/4462)), @devorun ([#4601](https://github.com/NousResearch/hermes-agent/issues/4601)), @eloklam ([#4486](https://github.com/NousResearch/hermes-agent/issues/4486)), @HenkDz ([#3719](https://github.com/NousResearch/hermes-agent/issues/3719)), @hypotyposis ([#2153](https://github.com/NousResearch/hermes-agent/issues/2153)), @kazamak ([#4178](https://github.com/NousResearch/hermes-agent/issues/4178)), @lstep ([#4366](https://github.com/NousResearch/hermes-agent/issues/4366)), @Mark-Lok ([#4542](https://github.com/NousResearch/hermes-agent/issues/4542)), @NoJster ([#4421](https://github.com/NousResearch/hermes-agent/issues/4421)), @patp ([#2662](https://github.com/NousResearch/hermes-agent/issues/2662)), @pr0n ([#4601](https://github.com/NousResearch/hermes-agent/issues/4601)), @saulmc ([#4377](https://github.com/NousResearch/hermes-agent/issues/4377)), @SHL0MS ([#4060](https://github.com/NousResearch/hermes-agent/issues/4060), [#4061](https://github.com/NousResearch/hermes-agent/issues/4061), [#4066](https://github.com/NousResearch/hermes-agent/issues/4066), [#4172](https://github.com/NousResearch/hermes-agent/issues/4172), [#4277](https://github.com/NousResearch/hermes-agent/issues/4277)), @Z-Mackintosh ([#4398](https://github.com/NousResearch/hermes-agent/issues/4398))
---
**Full Changelog**: [v2026.3.30...v2026.4.3](https://github.com/NousResearch/hermes-agent/compare/v2026.3.30...v2026.4.3)
+8 -4
View File
@@ -54,14 +54,18 @@ def make_tool_progress_cb(
Signature expected by AIAgent::
tool_progress_callback(name: str, preview: str, args: dict)
tool_progress_callback(event_type: str, name: str, preview: str, args: dict, **kwargs)
Emits ``ToolCallStart`` for each tool invocation and tracks IDs in a FIFO
Emits ``ToolCallStart`` for ``tool.started`` events and tracks IDs in a FIFO
queue per tool name so duplicate/parallel same-name calls still complete
against the correct ACP tool call.
against the correct ACP tool call. Other event types (``tool.completed``,
``reasoning.available``) are silently ignored.
"""
def _tool_progress(name: str, preview: str, args: Any = None) -> None:
def _tool_progress(event_type: str, name: str = None, preview: str = None, args: Any = None, **kwargs) -> None:
# Only emit ACP ToolCallStart for tool.started; ignore other event types
if event_type != "tool.started":
return
if isinstance(args, str):
try:
args = json.loads(args)
+207 -16
View File
@@ -12,7 +12,8 @@ import acp
from acp.schema import (
AgentCapabilities,
AuthenticateResponse,
AuthMethod,
AvailableCommand,
AvailableCommandsUpdate,
ClientCapabilities,
EmbeddedResourceContentBlock,
ForkSessionResponse,
@@ -22,6 +23,9 @@ from acp.schema import (
InitializeResponse,
ListSessionsResponse,
LoadSessionResponse,
McpServerHttp,
McpServerSse,
McpServerStdio,
NewSessionResponse,
PromptResponse,
ResumeSessionResponse,
@@ -34,9 +38,16 @@ from acp.schema import (
SessionListCapabilities,
SessionInfo,
TextContentBlock,
UnstructuredCommandInput,
Usage,
)
# AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0
try:
from acp.schema import AuthMethodAgent
except ImportError:
from acp.schema import AuthMethod as AuthMethodAgent # type: ignore[attr-defined]
from acp_adapter.auth import detect_provider, has_provider
from acp_adapter.events import (
make_message_cb,
@@ -81,6 +92,48 @@ def _extract_text(
class HermesACPAgent(acp.Agent):
"""ACP Agent implementation wrapping Hermes AIAgent."""
_SLASH_COMMANDS = {
"help": "Show available commands",
"model": "Show or change current model",
"tools": "List available tools",
"context": "Show conversation context info",
"reset": "Clear conversation history",
"compact": "Compress conversation context",
"version": "Show Hermes version",
}
_ADVERTISED_COMMANDS = (
{
"name": "help",
"description": "List available commands",
},
{
"name": "model",
"description": "Show current model and provider, or switch models",
"input_hint": "model name to switch to",
},
{
"name": "tools",
"description": "List available tools with descriptions",
},
{
"name": "context",
"description": "Show conversation message counts by role",
},
{
"name": "reset",
"description": "Clear conversation history",
},
{
"name": "compact",
"description": "Compress conversation context",
},
{
"name": "version",
"description": "Show Hermes version",
},
)
def __init__(self, session_manager: SessionManager | None = None):
super().__init__()
self.session_manager = session_manager or SessionManager()
@@ -93,6 +146,71 @@ class HermesACPAgent(acp.Agent):
self._conn = conn
logger.info("ACP client connected")
async def _register_session_mcp_servers(
self,
state: SessionState,
mcp_servers: list[McpServerStdio | McpServerHttp | McpServerSse] | None,
) -> None:
"""Register ACP-provided MCP servers and refresh the agent tool surface."""
if not mcp_servers:
return
try:
from tools.mcp_tool import register_mcp_servers
config_map: dict[str, dict] = {}
for server in mcp_servers:
name = server.name
if isinstance(server, McpServerStdio):
config = {
"command": server.command,
"args": list(server.args),
"env": {item.name: item.value for item in server.env},
}
else:
config = {
"url": server.url,
"headers": {item.name: item.value for item in server.headers},
}
config_map[name] = config
await asyncio.to_thread(register_mcp_servers, config_map)
except Exception:
logger.warning(
"Session %s: failed to register ACP MCP servers",
state.session_id,
exc_info=True,
)
return
try:
from model_tools import get_tool_definitions
enabled_toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
disabled_toolsets = getattr(state.agent, "disabled_toolsets", None)
state.agent.tools = get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=True,
)
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
}
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
if callable(invalidate):
invalidate()
logger.info(
"Session %s: refreshed tool surface after ACP MCP registration (%d tools)",
state.session_id,
len(state.agent.tools or []),
)
except Exception:
logger.warning(
"Session %s: failed to refresh tool surface after ACP MCP registration",
state.session_id,
exc_info=True,
)
# ---- ACP lifecycle ------------------------------------------------------
async def initialize(
@@ -109,7 +227,7 @@ class HermesACPAgent(acp.Agent):
auth_methods = None
if provider:
auth_methods = [
AuthMethod(
AuthMethodAgent(
id=provider,
name=f"{provider} runtime credentials",
description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.",
@@ -149,7 +267,9 @@ class HermesACPAgent(acp.Agent):
**kwargs: Any,
) -> NewSessionResponse:
state = self.session_manager.create_session(cwd=cwd)
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("New session %s (cwd=%s)", state.session_id, cwd)
self._schedule_available_commands_update(state.session_id)
return NewSessionResponse(session_id=state.session_id)
async def load_session(
@@ -163,7 +283,9 @@ class HermesACPAgent(acp.Agent):
if state is None:
logger.warning("load_session: session %s not found", session_id)
return None
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("Loaded session %s", session_id)
self._schedule_available_commands_update(session_id)
return LoadSessionResponse()
async def resume_session(
@@ -177,7 +299,9 @@ class HermesACPAgent(acp.Agent):
if state is None:
logger.warning("resume_session: session %s not found, creating new", session_id)
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)
self._schedule_available_commands_update(state.session_id)
return ResumeSessionResponse()
async def cancel(self, session_id: str, **kwargs: Any) -> None:
@@ -200,7 +324,11 @@ class HermesACPAgent(acp.Agent):
) -> ForkSessionResponse:
state = self.session_manager.fork_session(session_id, cwd=cwd)
new_id = state.session_id if state else ""
if state is not None:
await self._register_session_mcp_servers(state, mcp_servers)
logger.info("Forked session %s -> %s", session_id, new_id)
if new_id:
self._schedule_available_commands_update(new_id)
return ForkSessionResponse(session_id=new_id)
async def list_sessions(
@@ -338,15 +466,50 @@ class HermesACPAgent(acp.Agent):
# ---- Slash commands (headless) -------------------------------------------
_SLASH_COMMANDS = {
"help": "Show available commands",
"model": "Show or change current model",
"tools": "List available tools",
"context": "Show conversation context info",
"reset": "Clear conversation history",
"compact": "Compress conversation context",
"version": "Show Hermes version",
}
@classmethod
def _available_commands(cls) -> list[AvailableCommand]:
commands: list[AvailableCommand] = []
for spec in cls._ADVERTISED_COMMANDS:
input_hint = spec.get("input_hint")
commands.append(
AvailableCommand(
name=spec["name"],
description=spec["description"],
input=UnstructuredCommandInput(hint=input_hint)
if input_hint
else None,
)
)
return commands
async def _send_available_commands_update(self, session_id: str) -> None:
"""Advertise supported slash commands to the connected ACP client."""
if not self._conn:
return
try:
await self._conn.session_update(
session_id=session_id,
update=AvailableCommandsUpdate(
sessionUpdate="available_commands_update",
availableCommands=self._available_commands(),
),
)
except Exception:
logger.warning(
"Failed to advertise ACP slash commands for session %s",
session_id,
exc_info=True,
)
def _schedule_available_commands_update(self, session_id: str) -> None:
"""Send the command advertisement after the session response is queued."""
if not self._conn:
return
loop = asyncio.get_running_loop()
loop.call_soon(
asyncio.create_task, self._send_available_commands_update(session_id)
)
def _handle_slash_command(self, text: str, state: SessionState) -> str | None:
"""Dispatch a slash command and return the response text.
@@ -466,11 +629,39 @@ class HermesACPAgent(acp.Agent):
return "Nothing to compress — conversation is empty."
try:
agent = state.agent
if hasattr(agent, "compress_context"):
agent.compress_context(state.history)
self.session_manager.save_session(state.session_id)
return f"Context compressed. Messages: {len(state.history)}"
return "Context compression not available for this agent."
if not getattr(agent, "compression_enabled", True):
return "Context compression is disabled for this agent."
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
from agent.model_metadata import estimate_messages_tokens_rough
original_count = len(state.history)
approx_tokens = estimate_messages_tokens_rough(state.history)
original_session_db = getattr(agent, "_session_db", None)
try:
# ACP sessions must keep a stable session id, so avoid the
# SQLite session-splitting side effect inside _compress_context.
agent._session_db = None
compressed, _ = agent._compress_context(
state.history,
getattr(agent, "_cached_system_prompt", "") or "",
approx_tokens=approx_tokens,
task_id=state.session_id,
)
finally:
agent._session_db = original_session_db
state.history = compressed
self.session_manager.save_session(state.session_id)
new_count = len(state.history)
new_tokens = estimate_messages_tokens_rough(state.history)
return (
f"Context compressed: {original_count} -> {new_count} messages\n"
f"~{approx_tokens:,} -> ~{new_tokens:,} tokens"
)
except Exception as e:
return f"Compression failed: {e}"
+17 -1
View File
@@ -13,6 +13,7 @@ from hermes_constants import get_hermes_home
import copy
import json
import logging
import sys
import uuid
from dataclasses import dataclass, field
from threading import Lock
@@ -21,6 +22,17 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _acp_stderr_print(*args, **kwargs) -> None:
"""Best-effort human-readable output sink for ACP stdio sessions.
ACP reserves stdout for JSON-RPC frames, so any incidental CLI/status output
from AIAgent must be redirected away from stdout. Route it to stderr instead.
"""
kwargs = dict(kwargs)
kwargs.setdefault("file", sys.stderr)
print(*args, **kwargs)
def _register_task_cwd(task_id: str, cwd: str) -> None:
"""Bind a task/session id to the editor's working directory for tools."""
if not task_id:
@@ -458,4 +470,8 @@ class SessionManager:
logger.debug("ACP session falling back to default provider resolution", exc_info=True)
_register_task_cwd(session_id, cwd)
return AIAgent(**kwargs)
agent = AIAgent(**kwargs)
# ACP stdio transport requires stdout to remain protocol-only JSON-RPC.
# Route any incidental human-readable agent output to stderr instead.
agent._print_fn = _acp_stderr_print
return agent
+70 -2
View File
@@ -10,6 +10,7 @@ Auth supports:
- Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) → Bearer auth
"""
import copy
import json
import logging
import os
@@ -949,6 +950,69 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
return block
def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) -> Any:
"""Recursively convert SDK objects to plain Python data structures.
Guards against circular references (``_path`` tracks ``id()`` of objects
on the *current* recursion path) and runaway depth (capped at 20 levels).
Uses path-based tracking so shared (but non-cyclic) objects referenced by
multiple siblings are converted correctly rather than being stringified.
"""
_MAX_DEPTH = 20
if _depth > _MAX_DEPTH:
return str(value)
if _path is None:
_path = set()
obj_id = id(value)
if obj_id in _path:
return str(value)
if hasattr(value, "model_dump"):
_path.add(obj_id)
result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path)
_path.discard(obj_id)
return result
if isinstance(value, dict):
_path.add(obj_id)
result = {k: _to_plain_data(v, _depth=_depth + 1, _path=_path) for k, v in value.items()}
_path.discard(obj_id)
return result
if isinstance(value, (list, tuple)):
_path.add(obj_id)
result = [_to_plain_data(v, _depth=_depth + 1, _path=_path) for v in value]
_path.discard(obj_id)
return result
if hasattr(value, "__dict__"):
_path.add(obj_id)
result = {
k: _to_plain_data(v, _depth=_depth + 1, _path=_path)
for k, v in vars(value).items()
if not k.startswith("_")
}
_path.discard(obj_id)
return result
return value
def _extract_preserved_thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return Anthropic thinking blocks previously preserved on the message."""
raw_details = message.get("reasoning_details")
if not isinstance(raw_details, list):
return []
preserved: List[Dict[str, Any]] = []
for detail in raw_details:
if not isinstance(detail, dict):
continue
block_type = str(detail.get("type", "") or "").strip().lower()
if block_type not in {"thinking", "redacted_thinking"}:
continue
preserved.append(copy.deepcopy(detail))
return preserved
def _convert_content_to_anthropic(content: Any) -> Any:
"""Convert OpenAI-style multimodal content arrays to Anthropic blocks."""
if not isinstance(content, list):
@@ -995,7 +1059,7 @@ def convert_messages_to_anthropic(
continue
if role == "assistant":
blocks = []
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
converted_content = _convert_content_to_anthropic(content)
@@ -1279,6 +1343,7 @@ def normalize_anthropic_response(
"""
text_parts = []
reasoning_parts = []
reasoning_details = []
tool_calls = []
for block in response.content:
@@ -1286,6 +1351,9 @@ def normalize_anthropic_response(
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_TOOL_PREFIX):
@@ -1316,7 +1384,7 @@ def normalize_anthropic_response(
tool_calls=tool_calls or None,
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
reasoning_content=None,
reasoning_details=None,
reasoning_details=reasoning_details or None,
),
finish_reason,
)
+170 -16
View File
@@ -34,6 +34,12 @@ than the provider's default.
Per-task direct endpoint overrides (e.g. AUXILIARY_VISION_BASE_URL,
AUXILIARY_VISION_API_KEY) let callers route a specific auxiliary task to a
custom OpenAI-compatible endpoint without touching the main model settings.
Payment / credit exhaustion fallback:
When a resolved provider returns HTTP 402 or a credit-related error,
call_llm() automatically retries with the next available provider in the
auto-detection chain. This handles the common case where a user depletes
their OpenRouter balance but has Codex OAuth or another provider available.
"""
import json
@@ -55,6 +61,7 @@ logger = logging.getLogger(__name__)
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
"minimax": "MiniMax-M2.7-highspeed",
@@ -697,6 +704,25 @@ def _read_main_model() -> str:
return ""
def _read_main_provider() -> str:
"""Read the user's configured main provider from config.yaml.
Returns the lowercase provider id (e.g. "alibaba", "openrouter") or ""
if not configured.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
provider = model_cfg.get("provider", "")
if isinstance(provider, str) and provider.strip():
return provider.strip().lower()
except Exception:
pass
return ""
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str]]:
"""Resolve the active custom/main endpoint the same way the main CLI does.
@@ -823,7 +849,7 @@ def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[st
if forced == "nous":
client, model = _try_nous()
if client is None:
logger.warning("auxiliary.provider=nous but Nous Portal not configured (run: hermes login)")
logger.warning("auxiliary.provider=nous but Nous Portal not configured (run: hermes auth)")
return client, model
if forced == "codex":
@@ -854,16 +880,118 @@ _AUTO_PROVIDER_LABELS = {
"_resolve_api_key_provider": "api-key",
}
_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"})
def _get_provider_chain() -> List[tuple]:
"""Return the ordered provider detection chain.
Built at call time (not module level) so that test patches
on the ``_try_*`` functions are picked up correctly.
"""
return [
("openrouter", _try_openrouter),
("nous", _try_nous),
("local/custom", _try_custom_endpoint),
("openai-codex", _try_codex),
("api-key", _resolve_api_key_provider),
]
def _is_payment_error(exc: Exception) -> bool:
"""Detect payment/credit/quota exhaustion errors.
Returns True for HTTP 402 (Payment Required) and for 429/other errors
whose message indicates billing exhaustion rather than rate limiting.
"""
status = getattr(exc, "status_code", None)
if status == 402:
return True
err_lower = str(exc).lower()
# OpenRouter and other providers include "credits" or "afford" in 402 bodies,
# but sometimes wrap them in 429 or other codes.
if status in (402, 429, None):
if any(kw in err_lower for kw in ("credits", "insufficient funds",
"can only afford", "billing",
"payment required")):
return True
return False
def _try_payment_fallback(
failed_provider: str,
task: str = None,
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try alternative providers after a payment/credit error.
Iterates the standard auto-detection chain, skipping the provider that
returned a payment error.
Returns:
(client, model, provider_label) or (None, None, "") if no fallback.
"""
# Normalise the failed provider label for matching.
skip = failed_provider.lower().strip()
# Also skip Step-1 main-provider path if it maps to the same backend.
# (e.g. main_provider="openrouter" → skip "openrouter" in chain)
main_provider = _read_main_provider()
skip_labels = {skip}
if main_provider and main_provider.lower() in skip:
skip_labels.add(main_provider.lower())
# Map common resolved_provider values back to chain labels.
_alias_to_label = {"openrouter": "openrouter", "nous": "nous",
"openai-codex": "openai-codex", "codex": "openai-codex",
"custom": "local/custom", "local/custom": "local/custom"}
skip_chain_labels = {_alias_to_label.get(s, s) for s in skip_labels}
tried = []
for label, try_fn in _get_provider_chain():
if label in skip_chain_labels:
continue
client, model = try_fn()
if client is not None:
logger.info(
"Auxiliary %s: payment error on %s — falling back to %s (%s)",
task or "call", failed_provider, label, model or "default",
)
return client, model, label
tried.append(label)
logger.warning(
"Auxiliary %s: payment error on %s and no fallback available (tried: %s)",
task or "call", failed_provider, ", ".join(tried),
)
return None, None, ""
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain: OpenRouter → Nous → custom → Codex → API-key → None."""
"""Full auto-detection chain.
Priority:
1. If the user's main provider is NOT an aggregator (OpenRouter / Nous),
use their main provider + main model directly. This ensures users on
Alibaba, DeepSeek, ZAI, etc. get auxiliary tasks handled by the same
provider they already have credentials for — no OpenRouter key needed.
2. OpenRouter → Nous → custom → Codex → API-key providers (original chain).
"""
global auxiliary_is_nous
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
# ── Step 1: non-aggregator main provider → use main model directly ──
main_provider = _read_main_provider()
main_model = _read_main_model()
if (main_provider and main_model
and main_provider not in _AGGREGATOR_PROVIDERS
and main_provider not in ("auto", "custom", "")):
client, resolved = resolve_provider_client(main_provider, main_model)
if client is not None:
logger.info("Auxiliary auto-detect: using main provider %s (%s)",
main_provider, resolved or main_model)
return client, resolved or main_model
# ── Step 2: aggregator / fallback chain ──────────────────────────────
tried = []
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
_try_codex, _resolve_api_key_provider):
fn_name = getattr(try_fn, "__name__", "unknown")
label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name)
for label, try_fn in _get_provider_chain():
client, model = try_fn()
if client is not None:
if tried:
@@ -991,7 +1119,7 @@ def resolve_provider_client(
client, default = _try_nous()
if client is None:
logger.warning("resolve_provider_client: nous requested "
"but Nous Portal not configured (run: hermes login)")
"but Nous Portal not configured (run: hermes auth)")
return None, None
final_model = model or default
return (_to_async_client(client, final_model) if async_mode
@@ -1078,9 +1206,9 @@ def resolve_provider_client(
tried_sources = list(pconfig.api_key_env_vars)
if provider == "copilot":
tried_sources.append("gh auth token")
logger.warning("resolve_provider_client: provider %s has no API "
"key configured (tried: %s)",
provider, ", ".join(tried_sources))
logger.debug("resolve_provider_client: provider %s has no API "
"key configured (tried: %s)",
provider, ", ".join(tried_sources))
return None, None
base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
@@ -1741,12 +1869,15 @@ def call_llm(
f"was found. Set the {_explicit.upper()}_API_KEY environment "
f"variable, or switch to a different provider with `hermes model`."
)
# For auto/custom, fall back to OpenRouter
# For auto/custom with no credentials, try the full auto chain
# rather than hardcoding OpenRouter (which may be depleted).
# Pass model=None so each provider uses its own default —
# resolved_model may be an OpenRouter-format slug that doesn't
# work on other providers.
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter",
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client(
"openrouter", resolved_model or _OPENROUTER_MODEL)
client, final_model = _get_cached_client("auto")
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
@@ -1767,7 +1898,7 @@ def call_llm(
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)
# Handle max_tokens vs max_completion_tokens retry
# Handle max_tokens vs max_completion_tokens retry, then payment fallback.
try:
return client.chat.completions.create(**kwargs)
except Exception as first_err:
@@ -1775,7 +1906,30 @@ def call_llm(
if "max_tokens" in err_str or "unsupported_parameter" in err_str:
kwargs.pop("max_tokens", None)
kwargs["max_completion_tokens"] = max_tokens
return client.chat.completions.create(**kwargs)
try:
return client.chat.completions.create(**kwargs)
except Exception as retry_err:
# If the max_tokens retry also hits a payment error,
# fall through to the payment fallback below.
if not _is_payment_error(retry_err):
raise
first_err = retry_err
# ── Payment / credit exhaustion fallback ──────────────────────
# When the resolved provider returns 402 or a credit-related error,
# try alternative providers instead of giving up. This handles the
# common case where a user runs out of OpenRouter credits but has
# Codex OAuth or another provider available.
if _is_payment_error(first_err):
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task)
if fb_client is not None:
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=extra_body)
return fb_client.chat.completions.create(**fb_kwargs)
raise
+113
View File
@@ -0,0 +1,113 @@
"""BuiltinMemoryProvider — wraps MEMORY.md / USER.md as a MemoryProvider.
Always registered as the first provider. Cannot be disabled or removed.
This is the existing Hermes memory system exposed through the provider
interface for compatibility with the MemoryManager.
The actual storage logic lives in tools/memory_tool.py (MemoryStore).
This provider is a thin adapter that delegates to MemoryStore and
exposes the memory tool schema.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
class BuiltinMemoryProvider(MemoryProvider):
"""Built-in file-backed memory (MEMORY.md + USER.md).
Always active, never disabled by other providers. The `memory` tool
is handled by run_agent.py's agent-level tool interception (not through
the normal registry), so get_tool_schemas() returns an empty list —
the memory tool is already wired separately.
"""
def __init__(
self,
memory_store=None,
memory_enabled: bool = False,
user_profile_enabled: bool = False,
):
self._store = memory_store
self._memory_enabled = memory_enabled
self._user_profile_enabled = user_profile_enabled
@property
def name(self) -> str:
return "builtin"
def is_available(self) -> bool:
"""Built-in memory is always available."""
return True
def initialize(self, session_id: str, **kwargs) -> None:
"""Load memory from disk if not already loaded."""
if self._store is not None:
self._store.load_from_disk()
def system_prompt_block(self) -> str:
"""Return MEMORY.md and USER.md content for the system prompt.
Uses the frozen snapshot captured at load time. This ensures the
system prompt stays stable throughout a session (preserving the
prompt cache), even though the live entries may change via tool calls.
"""
if not self._store:
return ""
parts = []
if self._memory_enabled:
mem_block = self._store.format_for_system_prompt("memory")
if mem_block:
parts.append(mem_block)
if self._user_profile_enabled:
user_block = self._store.format_for_system_prompt("user")
if user_block:
parts.append(user_block)
return "\n\n".join(parts)
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Built-in memory doesn't do query-based recall — it's injected via system_prompt_block."""
return ""
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Built-in memory doesn't auto-sync turns — writes happen via the memory tool."""
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return empty list.
The `memory` tool is an agent-level intercepted tool, handled
specially in run_agent.py before normal tool dispatch. It's not
part of the standard tool registry. We don't duplicate it here.
"""
return []
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
"""Not used — the memory tool is intercepted in run_agent.py."""
return json.dumps({"error": "Built-in memory tool is handled by the agent loop"})
def shutdown(self) -> None:
"""No cleanup needed — files are saved on every write."""
# -- Property access for backward compatibility --------------------------
@property
def store(self):
"""Access the underlying MemoryStore for legacy code paths."""
return self._store
@property
def memory_enabled(self) -> bool:
return self._memory_enabled
@property
def user_profile_enabled(self) -> bool:
return self._user_profile_enabled
+24 -4
View File
@@ -14,6 +14,7 @@ Improvements over v1:
"""
import logging
import time
from typing import Any, Dict, List, Optional
from agent.auxiliary_client import call_llm
@@ -46,6 +47,7 @@ _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
# Chars per token rough estimate
_CHARS_PER_TOKEN = 4
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
class ContextCompressor:
@@ -118,6 +120,7 @@ class ContextCompressor:
# Stores the previous compaction summary for iterative updates
self._previous_summary: Optional[str] = None
self._summary_failure_cooldown_until: float = 0.0
def update_from_response(self, usage: Dict[str, Any]):
"""Update tracked token usage from API response."""
@@ -258,6 +261,14 @@ class ContextCompressor:
the middle turns without a summary rather than inject a useless
placeholder.
"""
now = time.monotonic()
if now < self._summary_failure_cooldown_until:
logger.debug(
"Skipping context summary during cooldown (%.0fs remaining)",
self._summary_failure_cooldown_until - now,
)
return None
summary_budget = self._compute_summary_budget(turns_to_summarize)
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
@@ -345,7 +356,6 @@ Write only the summary body. Do not include any preamble or prefix."""
call_kwargs = {
"task": "compression",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": summary_budget * 2,
# timeout resolved from auxiliary.compression.timeout config by call_llm
}
@@ -359,13 +369,23 @@ Write only the summary body. Do not include any preamble or prefix."""
summary = content.strip()
# Store for iterative updates on next compaction
self._previous_summary = summary
self._summary_failure_cooldown_until = 0.0
return self._with_summary_prefix(summary)
except RuntimeError:
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
logging.warning("Context compression: no provider available for "
"summary. Middle turns will be dropped without summary.")
"summary. Middle turns will be dropped without summary "
"for %d seconds.",
_SUMMARY_FAILURE_COOLDOWN_SECONDS)
return None
except Exception as e:
logging.warning("Failed to generate context summary: %s", e)
self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS
logging.warning(
"Failed to generate context summary: %s. "
"Further summary attempts paused for %d seconds.",
e,
_SUMMARY_FAILURE_COOLDOWN_SECONDS,
)
return None
@staticmethod
@@ -648,7 +668,7 @@ Write only the summary body. Do not include any preamble or prefix."""
compressed.append({"role": summary_role, "content": summary})
else:
if not self.quiet_mode:
logger.warning("No summary model available — middle turns dropped without summary")
logger.debug("No summary model available — middle turns dropped without summary")
for i in range(compress_end, n_messages):
msg = messages[i].copy()
+130 -7
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import os
import queue
import re
import shlex
import subprocess
import threading
@@ -23,6 +24,9 @@ from typing import Any
ACP_MARKER_BASE_URL = "acp://copilot"
_DEFAULT_TIMEOUT_SECONDS = 900.0
_TOOL_CALL_BLOCK_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL)
def _resolve_command() -> str:
return (
@@ -50,15 +54,50 @@ def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]:
}
def _format_messages_as_prompt(messages: list[dict[str, Any]], model: str | None = None) -> str:
def _format_messages_as_prompt(
messages: list[dict[str, Any]],
model: str | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: Any = None,
) -> str:
sections: list[str] = [
"You are being used as the active ACP agent backend for Hermes.",
"Use your own ACP capabilities and respond directly in natural language.",
"Do not emit OpenAI tool-call JSON.",
"Use ACP capabilities to complete tasks.",
"IMPORTANT: If you take an action with a tool, you MUST output tool calls using <tool_call>{...}</tool_call> blocks with JSON exactly in OpenAI function-call shape.",
"If no tool is needed, answer normally.",
]
if model:
sections.append(f"Hermes requested model hint: {model}")
if isinstance(tools, list) and tools:
tool_specs: list[dict[str, Any]] = []
for t in tools:
if not isinstance(t, dict):
continue
fn = t.get("function") or {}
if not isinstance(fn, dict):
continue
name = fn.get("name")
if not isinstance(name, str) or not name.strip():
continue
tool_specs.append(
{
"name": name.strip(),
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {}),
}
)
if tool_specs:
sections.append(
"Available tools (OpenAI function schema). "
"When using a tool, emit ONLY <tool_call>{...}</tool_call> with one JSON object "
"containing id/type/function{name,arguments}. arguments must be a JSON string.\n"
+ json.dumps(tool_specs, ensure_ascii=False)
)
if tool_choice is not None:
sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}")
transcript: list[str] = []
for message in messages:
if not isinstance(message, dict):
@@ -114,6 +153,80 @@ def _render_message_content(content: Any) -> str:
return str(content).strip()
def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
if not isinstance(text, str) or not text.strip():
return [], ""
extracted: list[SimpleNamespace] = []
consumed_spans: list[tuple[int, int]] = []
def _try_add_tool_call(raw_json: str) -> None:
try:
obj = json.loads(raw_json)
except Exception:
return
if not isinstance(obj, dict):
return
fn = obj.get("function")
if not isinstance(fn, dict):
return
fn_name = fn.get("name")
if not isinstance(fn_name, str) or not fn_name.strip():
return
fn_args = fn.get("arguments", "{}")
if not isinstance(fn_args, str):
fn_args = json.dumps(fn_args, ensure_ascii=False)
call_id = obj.get("id")
if not isinstance(call_id, str) or not call_id.strip():
call_id = f"acp_call_{len(extracted)+1}"
extracted.append(
SimpleNamespace(
id=call_id,
call_id=call_id,
response_item_id=None,
type="function",
function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
)
)
for m in _TOOL_CALL_BLOCK_RE.finditer(text):
raw = m.group(1)
_try_add_tool_call(raw)
consumed_spans.append((m.start(), m.end()))
# Only try bare-JSON fallback when no XML blocks were found.
if not extracted:
for m in _TOOL_CALL_JSON_RE.finditer(text):
raw = m.group(0)
_try_add_tool_call(raw)
consumed_spans.append((m.start(), m.end()))
if not consumed_spans:
return extracted, text.strip()
consumed_spans.sort()
merged: list[tuple[int, int]] = []
for start, end in consumed_spans:
if not merged or start > merged[-1][1]:
merged.append((start, end))
else:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
parts: list[str] = []
cursor = 0
for start, end in merged:
if cursor < start:
parts.append(text[cursor:start])
cursor = max(cursor, end)
if cursor < len(text):
parts.append(text[cursor:])
cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip()
return extracted, cleaned
def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path:
candidate = Path(path_text)
if not candidate.is_absolute():
@@ -190,14 +303,23 @@ class CopilotACPClient:
model: str | None = None,
messages: list[dict[str, Any]] | None = None,
timeout: float | None = None,
tools: list[dict[str, Any]] | None = None,
tool_choice: Any = None,
**_: Any,
) -> Any:
prompt_text = _format_messages_as_prompt(messages or [], model=model)
prompt_text = _format_messages_as_prompt(
messages or [],
model=model,
tools=tools,
tool_choice=tool_choice,
)
response_text, reasoning_text = self._run_prompt(
prompt_text,
timeout_seconds=float(timeout or _DEFAULT_TIMEOUT_SECONDS),
)
tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text)
usage = SimpleNamespace(
prompt_tokens=0,
completion_tokens=0,
@@ -205,13 +327,14 @@ class CopilotACPClient:
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
)
assistant_message = SimpleNamespace(
content=response_text,
tool_calls=[],
content=cleaned_text,
tool_calls=tool_calls,
reasoning=reasoning_text or None,
reasoning_content=reasoning_text or None,
reasoning_details=None,
)
choice = SimpleNamespace(message=assistant_message, finish_reason="stop")
finish_reason = "tool_calls" if tool_calls else "stop"
choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
return SimpleNamespace(
choices=[choice],
usage=usage,
+319 -10
View File
@@ -8,7 +8,9 @@ import threading
import time
import uuid
import os
import re
from dataclasses import dataclass, fields, replace
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
@@ -21,6 +23,7 @@ from hermes_cli.auth import (
_agent_key_is_usable,
_codex_access_token_is_expiring,
_decode_jwt_claims,
_import_codex_cli_tokens,
_is_expiring,
_load_auth_store,
_load_provider_state,
@@ -95,6 +98,9 @@ class PooledCredential:
last_status: Optional[str] = None
last_status_at: Optional[float] = None
last_error_code: Optional[int] = None
last_error_reason: Optional[str] = None
last_error_message: Optional[str] = None
last_error_reset_at: Optional[float] = None
base_url: Optional[str] = None
expires_at: Optional[str] = None
expires_at_ms: Optional[int] = None
@@ -129,7 +135,14 @@ class PooledCredential:
return cls(provider=provider, **data)
def to_dict(self) -> Dict[str, Any]:
_ALWAYS_EMIT = {"last_status", "last_status_at", "last_error_code"}
_ALWAYS_EMIT = {
"last_status",
"last_status_at",
"last_error_code",
"last_error_reason",
"last_error_message",
"last_error_reset_at",
}
result: Dict[str, Any] = {}
for field_def in fields(self):
if field_def.name in ("provider", "extra"):
@@ -180,6 +193,85 @@ def _exhausted_ttl(error_code: Optional[int]) -> int:
return EXHAUSTED_TTL_DEFAULT_SECONDS
def _parse_absolute_timestamp(value: Any) -> Optional[float]:
"""Best-effort parse for provider reset timestamps.
Accepts epoch seconds, epoch milliseconds, and ISO-8601 strings.
Returns seconds since epoch.
"""
if value is None or value == "":
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
except ValueError:
numeric = None
if numeric is not None:
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
def _extract_retry_delay_seconds(message: str) -> Optional[float]:
if not message:
return None
delay_match = re.search(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", message, re.IGNORECASE)
if delay_match:
value = float(delay_match.group(1))
return value / 1000.0 if delay_match.group(2).lower() == "ms" else value
sec_match = re.search(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?:sec|secs|seconds|s\b)", message, re.IGNORECASE)
if sec_match:
return float(sec_match.group(1))
return None
def _normalize_error_context(error_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(error_context, dict):
return {}
normalized: Dict[str, Any] = {}
reason = error_context.get("reason")
if isinstance(reason, str) and reason.strip():
normalized["reason"] = reason.strip()
message = error_context.get("message")
if isinstance(message, str) and message.strip():
normalized["message"] = message.strip()
reset_at = (
error_context.get("reset_at")
or error_context.get("resets_at")
or error_context.get("retry_until")
)
parsed_reset_at = _parse_absolute_timestamp(reset_at)
if parsed_reset_at is None and isinstance(message, str):
retry_delay_seconds = _extract_retry_delay_seconds(message)
if retry_delay_seconds is not None:
parsed_reset_at = time.time() + retry_delay_seconds
if parsed_reset_at is not None:
normalized["reset_at"] = parsed_reset_at
return normalized
def _exhausted_until(entry: PooledCredential) -> Optional[float]:
if entry.last_status != STATUS_EXHAUSTED:
return None
reset_at = _parse_absolute_timestamp(getattr(entry, "last_error_reset_at", None))
if reset_at is not None:
return reset_at
if entry.last_status_at:
return entry.last_status_at + _exhausted_ttl(entry.last_error_code)
return None
def _normalize_custom_pool_name(name: str) -> str:
"""Normalize a custom provider name for use as a pool key suffix."""
return name.strip().lower().replace(" ", "-")
@@ -292,17 +384,96 @@ class CredentialPool:
[entry.to_dict() for entry in self._entries],
)
def _mark_exhausted(self, entry: PooledCredential, status_code: Optional[int]) -> PooledCredential:
def _mark_exhausted(
self,
entry: PooledCredential,
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
) -> PooledCredential:
normalized_error = _normalize_error_context(error_context)
updated = replace(
entry,
last_status=STATUS_EXHAUSTED,
last_status_at=time.time(),
last_error_code=status_code,
last_error_reason=normalized_error.get("reason"),
last_error_message=normalized_error.get("message"),
last_error_reset_at=normalized_error.get("reset_at"),
)
self._replace_entry(entry, updated)
self._persist()
return updated
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
OAuth refresh tokens are single-use. When something external (e.g.
Claude Code CLI, or another profile's pool) refreshes the token, it
writes the new pair to ~/.claude/.credentials.json. The pool entry's
refresh token becomes stale. This method detects that and syncs.
"""
if self.provider != "anthropic" or entry.source != "claude_code":
return entry
try:
from agent.anthropic_adapter import read_claude_code_credentials
creds = read_claude_code_credentials()
if not creds:
return entry
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
# If the credentials file has a different token pair, sync it
if file_refresh and file_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
updated = replace(
entry,
access_token=file_access,
refresh_token=file_refresh,
expires_at_ms=file_expires,
last_status=None,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(entry, updated)
self._persist()
return updated
except Exception as exc:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential:
"""Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ.
OpenAI OAuth refresh tokens are single-use and rotate on every refresh.
When the Codex CLI (or another Hermes profile) refreshes its token,
the pool entry's refresh_token becomes stale. This method detects that
by comparing against ~/.codex/auth.json and syncing the fresh pair.
"""
if self.provider != "openai-codex":
return entry
try:
cli_tokens = _import_codex_cli_tokens()
if not cli_tokens:
return entry
cli_refresh = cli_tokens.get("refresh_token", "")
cli_access = cli_tokens.get("access_token", "")
if cli_refresh and cli_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)", entry.id)
updated = replace(
entry,
access_token=cli_access,
refresh_token=cli_refresh,
last_status=None,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(entry, updated)
self._persist()
return updated
except Exception as exc:
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
return entry
def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
if force:
@@ -323,6 +494,19 @@ class CredentialPool:
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
# Keep ~/.claude/.credentials.json in sync so that the
# fallback path (resolve_anthropic_token) and other profiles
# see the latest tokens.
if entry.source == "claude_code":
try:
from agent.anthropic_adapter import _write_claude_code_credentials
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
elif self.provider == "openai-codex":
refreshed = auth_mod.refresh_codex_oauth_pure(
entry.access_token,
@@ -369,10 +553,58 @@ class CredentialPool:
return entry
except Exception as exc:
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
# For anthropic claude_code entries: the refresh token may have been
# consumed by another process. Check if ~/.claude/.credentials.json
# has a newer token pair and retry once.
if self.provider == "anthropic" and entry.source == "claude_code":
synced = self._sync_anthropic_entry_from_credentials_file(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token,
use_json=synced.source.endswith("hermes_pkce"),
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(synced, updated)
self._persist()
try:
from agent.anthropic_adapter import _write_claude_code_credentials
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file (retry path): %s", wexc)
return updated
except Exception as retry_exc:
logger.debug("Retry refresh also failed: %s", retry_exc)
elif not self._entry_needs_refresh(synced):
# Credentials file had a valid (non-expired) token — use it directly
logger.debug("Credentials file has valid token, using without refresh")
return synced
self._mark_exhausted(entry, None)
return None
updated = replace(updated, last_status=STATUS_OK, last_status_at=None, last_error_code=None)
updated = replace(
updated,
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
self._replace_entry(entry, updated)
self._persist()
return updated
@@ -422,12 +654,39 @@ class CredentialPool:
cleared_any = False
available: List[PooledCredential] = []
for entry in self._entries:
# For anthropic claude_code entries, sync from the credentials file
# before any status/refresh checks. This picks up tokens refreshed
# by other processes (Claude Code CLI, other Hermes profiles).
if (self.provider == "anthropic" and entry.source == "claude_code"
and entry.last_status == STATUS_EXHAUSTED):
synced = self._sync_anthropic_entry_from_credentials_file(entry)
if synced is not entry:
entry = synced
cleared_any = True
# For openai-codex entries, sync from ~/.codex/auth.json before
# any status/refresh checks. This picks up tokens refreshed by
# the Codex CLI or another Hermes profile.
if (self.provider == "openai-codex"
and entry.last_status == STATUS_EXHAUSTED
and entry.refresh_token):
synced = self._sync_codex_entry_from_cli(entry)
if synced is not entry:
entry = synced
cleared_any = True
if entry.last_status == STATUS_EXHAUSTED:
ttl = _exhausted_ttl(entry.last_error_code)
if entry.last_status_at and now - entry.last_status_at < ttl:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
continue
if clear_expired:
cleared = replace(entry, last_status=STATUS_OK, last_status_at=None, last_error_code=None)
cleared = replace(
entry,
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
self._replace_entry(entry, cleared)
entry = cleared
cleared_any = True
@@ -445,6 +704,7 @@ class CredentialPool:
available = self._available_entries(clear_expired=True, refresh=True)
if not available:
self._current_id = None
logger.info("credential pool: no available entries (all exhausted or empty)")
return None
if self._strategy == STRATEGY_RANDOM:
@@ -477,14 +737,28 @@ class CredentialPool:
available = self._available_entries()
return available[0] if available else None
def mark_exhausted_and_rotate(self, *, status_code: Optional[int]) -> Optional[PooledCredential]:
def mark_exhausted_and_rotate(
self,
*,
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
) -> Optional[PooledCredential]:
with self._lock:
entry = self.current() or self._select_unlocked()
if entry is None:
return None
self._mark_exhausted(entry, status_code)
_label = entry.label or entry.id[:8]
logger.info(
"credential pool: marking %s exhausted (status=%s), rotating",
_label, status_code,
)
self._mark_exhausted(entry, status_code, error_context)
self._current_id = None
return self._select_unlocked()
next_entry = self._select_unlocked()
if next_entry:
_next_label = next_entry.label or next_entry.id[:8]
logger.info("credential pool: rotated to %s", _next_label)
return next_entry
def try_refresh_current(self) -> Optional[PooledCredential]:
with self._lock:
@@ -504,7 +778,17 @@ class CredentialPool:
new_entries = []
for entry in self._entries:
if entry.last_status or entry.last_status_at or entry.last_error_code:
new_entries.append(replace(entry, last_status=None, last_status_at=None, last_error_code=None))
new_entries.append(
replace(
entry,
last_status=None,
last_status_at=None,
last_error_code=None,
last_error_reason=None,
last_error_message=None,
last_error_reset_at=None,
)
)
count += 1
else:
new_entries.append(entry)
@@ -526,6 +810,31 @@ class CredentialPool:
self._current_id = None
return removed
def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
raw = str(target or "").strip()
if not raw:
return None, None, "No credential target provided."
for idx, entry in enumerate(self._entries, start=1):
if entry.id == raw:
return idx, entry, None
label_matches = [
(idx, entry)
for idx, entry in enumerate(self._entries, start=1)
if entry.label.strip().lower() == raw.lower()
]
if len(label_matches) == 1:
return label_matches[0][0], label_matches[0][1], None
if len(label_matches) > 1:
return None, None, f'Ambiguous credential label "{raw}". Use the numeric index or entry id instead.'
if raw.isdigit():
index = int(raw)
if 1 <= index <= len(self._entries):
return index, self._entries[index - 1], None
return None, None, f"No credential #{index}."
return None, None, f'No credential matching "{raw}".'
def add_entry(self, entry: PooledCredential) -> PooledCredential:
entry = replace(entry, priority=_next_priority(self._entries))
self._entries.append(entry)
+366
View File
@@ -0,0 +1,366 @@
"""MemoryManager — orchestrates the built-in memory provider plus at most
ONE external plugin memory provider.
Single integration point in run_agent.py. Replaces scattered per-backend
code with one manager that delegates to registered providers.
The BuiltinMemoryProvider is always registered first and cannot be removed.
Only ONE external (non-builtin) provider is allowed at a time attempting
to register a second external provider is rejected with a warning. This
prevents tool schema bloat and conflicting memory backends.
Usage in run_agent.py:
self._memory_manager = MemoryManager()
self._memory_manager.add_provider(BuiltinMemoryProvider(...))
# Only ONE of these:
self._memory_manager.add_provider(plugin_provider)
# System prompt
prompt_parts.append(self._memory_manager.build_system_prompt())
# Pre-turn
context = self._memory_manager.prefetch_all(user_message)
# Post-turn
self._memory_manager.sync_all(user_msg, assistant_response)
self._memory_manager.queue_prefetch_all(user_msg)
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Context fencing helpers
# ---------------------------------------------------------------------------
_FENCE_TAG_RE = re.compile(r'</?\s*memory-context\s*>', re.IGNORECASE)
def sanitize_context(text: str) -> str:
"""Strip fence-escape sequences from provider output."""
return _FENCE_TAG_RE.sub('', text)
def build_memory_context_block(raw_context: str) -> str:
"""Wrap prefetched memory in a fenced block with system note.
The fence prevents the model from treating recalled context as user
discourse. Injected at API-call time only never persisted.
"""
if not raw_context or not raw_context.strip():
return ""
clean = sanitize_context(raw_context)
return (
"<memory-context>\n"
"[System note: The following is recalled memory context, "
"NOT new user input. Treat as informational background data.]\n\n"
f"{clean}\n"
"</memory-context>"
)
class MemoryManager:
"""Orchestrates the built-in provider plus at most one external provider.
The builtin provider is always first. Only one non-builtin (external)
provider is allowed. Failures in one provider never block the other.
"""
def __init__(self) -> None:
self._providers: List[MemoryProvider] = []
self._tool_to_provider: Dict[str, MemoryProvider] = {}
self._has_external: bool = False # True once a non-builtin provider is added
# -- Registration --------------------------------------------------------
def add_provider(self, provider: MemoryProvider) -> None:
"""Register a memory provider.
Built-in provider (name ``"builtin"``) is always accepted.
Only **one** external (non-builtin) provider is allowed a second
attempt is rejected with a warning.
"""
is_builtin = provider.name == "builtin"
if not is_builtin:
if self._has_external:
existing = next(
(p.name for p in self._providers if p.name != "builtin"), "unknown"
)
logger.warning(
"Rejected memory provider '%s' — external provider '%s' is "
"already registered. Only one external memory provider is "
"allowed at a time. Configure which one via memory.provider "
"in config.yaml.",
provider.name, existing,
)
return
self._has_external = True
self._providers.append(provider)
# Index tool names → provider for routing
for schema in provider.get_tool_schemas():
tool_name = schema.get("name", "")
if tool_name and tool_name not in self._tool_to_provider:
self._tool_to_provider[tool_name] = provider
elif tool_name in self._tool_to_provider:
logger.warning(
"Memory tool name conflict: '%s' already registered by %s, "
"ignoring from %s",
tool_name,
self._tool_to_provider[tool_name].name,
provider.name,
)
logger.info(
"Memory provider '%s' registered (%d tools)",
provider.name,
len(provider.get_tool_schemas()),
)
@property
def providers(self) -> List[MemoryProvider]:
"""All registered providers in order."""
return list(self._providers)
@property
def provider_names(self) -> List[str]:
"""Names of all registered providers."""
return [p.name for p in self._providers]
def get_provider(self, name: str) -> Optional[MemoryProvider]:
"""Get a provider by name, or None if not registered."""
for p in self._providers:
if p.name == name:
return p
return None
# -- System prompt -------------------------------------------------------
def build_system_prompt(self) -> str:
"""Collect system prompt blocks from all providers.
Returns combined text, or empty string if no providers contribute.
Each non-empty block is labeled with the provider name.
"""
blocks = []
for provider in self._providers:
try:
block = provider.system_prompt_block()
if block and block.strip():
blocks.append(block)
except Exception as e:
logger.warning(
"Memory provider '%s' system_prompt_block() failed: %s",
provider.name, e,
)
return "\n\n".join(blocks)
# -- Prefetch / recall ---------------------------------------------------
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""Collect prefetch context from all providers.
Returns merged context text labeled by provider. Empty providers
are skipped. Failures in one provider don't block others.
"""
parts = []
for provider in self._providers:
try:
result = provider.prefetch(query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
logger.debug(
"Memory provider '%s' prefetch failed (non-fatal): %s",
provider.name, e,
)
return "\n\n".join(parts)
def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn."""
for provider in self._providers:
try:
provider.queue_prefetch(query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
provider.name, e,
)
# -- Sync ----------------------------------------------------------------
def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Sync a completed turn to all providers."""
for provider in self._providers:
try:
provider.sync_turn(user_content, assistant_content, session_id=session_id)
except Exception as e:
logger.warning(
"Memory provider '%s' sync_turn failed: %s",
provider.name, e,
)
# -- Tools ---------------------------------------------------------------
def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
"""Collect tool schemas from all providers."""
schemas = []
seen = set()
for provider in self._providers:
try:
for schema in provider.get_tool_schemas():
name = schema.get("name", "")
if name and name not in seen:
schemas.append(schema)
seen.add(name)
except Exception as e:
logger.warning(
"Memory provider '%s' get_tool_schemas() failed: %s",
provider.name, e,
)
return schemas
def get_all_tool_names(self) -> set:
"""Return set of all tool names across all providers."""
return set(self._tool_to_provider.keys())
def has_tool(self, tool_name: str) -> bool:
"""Check if any provider handles this tool."""
return tool_name in self._tool_to_provider
def handle_tool_call(
self, tool_name: str, args: Dict[str, Any], **kwargs
) -> str:
"""Route a tool call to the correct provider.
Returns JSON string result. Raises ValueError if no provider
handles the tool.
"""
provider = self._tool_to_provider.get(tool_name)
if provider is None:
return json.dumps({"error": f"No memory provider handles tool '{tool_name}'"})
try:
return provider.handle_tool_call(tool_name, args, **kwargs)
except Exception as e:
logger.error(
"Memory provider '%s' handle_tool_call(%s) failed: %s",
provider.name, tool_name, e,
)
return json.dumps({"error": f"Memory tool '{tool_name}' failed: {e}"})
# -- Lifecycle hooks -----------------------------------------------------
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
"""Notify all providers of a new turn.
kwargs may include: remaining_tokens, model, platform, tool_count.
"""
for provider in self._providers:
try:
provider.on_turn_start(turn_number, message, **kwargs)
except Exception as e:
logger.debug(
"Memory provider '%s' on_turn_start failed: %s",
provider.name, e,
)
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""Notify all providers of session end."""
for provider in self._providers:
try:
provider.on_session_end(messages)
except Exception as e:
logger.debug(
"Memory provider '%s' on_session_end failed: %s",
provider.name, e,
)
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Notify all providers before context compression.
Returns combined text from providers to include in the compression
summary prompt. Empty string if no provider contributes.
"""
parts = []
for provider in self._providers:
try:
result = provider.on_pre_compress(messages)
if result and result.strip():
parts.append(result)
except Exception as e:
logger.debug(
"Memory provider '%s' on_pre_compress failed: %s",
provider.name, e,
)
return "\n\n".join(parts)
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Notify external providers when the built-in memory tool writes.
Skips the builtin provider itself (it's the source of the write).
"""
for provider in self._providers:
if provider.name == "builtin":
continue
try:
provider.on_memory_write(action, target, content)
except Exception as e:
logger.debug(
"Memory provider '%s' on_memory_write failed: %s",
provider.name, e,
)
def on_delegation(self, task: str, result: str, *,
child_session_id: str = "", **kwargs) -> None:
"""Notify all providers that a subagent completed."""
for provider in self._providers:
try:
provider.on_delegation(
task, result, child_session_id=child_session_id, **kwargs
)
except Exception as e:
logger.debug(
"Memory provider '%s' on_delegation failed: %s",
provider.name, e,
)
def shutdown_all(self) -> None:
"""Shut down all providers (reverse order for clean teardown)."""
for provider in reversed(self._providers):
try:
provider.shutdown()
except Exception as e:
logger.warning(
"Memory provider '%s' shutdown failed: %s",
provider.name, e,
)
def initialize_all(self, session_id: str, **kwargs) -> None:
"""Initialize all providers.
Automatically injects ``hermes_home`` into *kwargs* so that every
provider can resolve profile-scoped storage paths without importing
``get_hermes_home()`` themselves.
"""
if "hermes_home" not in kwargs:
from hermes_constants import get_hermes_home
kwargs["hermes_home"] = str(get_hermes_home())
for provider in self._providers:
try:
provider.initialize(session_id=session_id, **kwargs)
except Exception as e:
logger.warning(
"Memory provider '%s' initialize failed: %s",
provider.name, e,
)
+231
View File
@@ -0,0 +1,231 @@
"""Abstract base class for pluggable memory providers.
Memory providers give the agent persistent recall across sessions. One
external provider is active at a time alongside the always-on built-in
memory (MEMORY.md / USER.md). The MemoryManager enforces this limit.
Built-in memory is always active as the first provider and cannot be removed.
External providers (Honcho, Hindsight, Mem0, etc.) are additive they never
disable the built-in store. Only one external provider runs at a time to
prevent tool schema bloat and conflicting memory backends.
Registration:
1. Built-in: BuiltinMemoryProvider always present, not removable.
2. Plugins: Ship in plugins/memory/<name>/, activated by memory.provider config.
Lifecycle (called by MemoryManager, wired in run_agent.py):
initialize() connect, create resources, warm up
system_prompt_block() static text for the system prompt
prefetch(query) background recall before each turn
sync_turn(user, asst) async write after each turn
get_tool_schemas() tool schemas to expose to the model
handle_tool_call() dispatch a tool call
shutdown() clean exit
Optional hooks (override to opt in):
on_turn_start(turn, message, **kwargs) per-turn tick with runtime context
on_session_end(messages) end-of-session extraction
on_pre_compress(messages) -> str extract before context compression
on_memory_write(action, target, content) mirror built-in memory writes
on_delegation(task, result, **kwargs) parent-side observation of subagent work
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class MemoryProvider(ABC):
"""Abstract base class for memory providers."""
@property
@abstractmethod
def name(self) -> str:
"""Short identifier for this provider (e.g. 'builtin', 'honcho', 'hindsight')."""
# -- Core lifecycle (implement these) ------------------------------------
@abstractmethod
def is_available(self) -> bool:
"""Return True if this provider is configured, has credentials, and is ready.
Called during agent init to decide whether to activate the provider.
Should not make network calls just check config and installed deps.
"""
@abstractmethod
def initialize(self, session_id: str, **kwargs) -> None:
"""Initialize for a session.
Called once at agent startup. May create resources (banks, tables),
establish connections, start background threads, etc.
kwargs always include:
- hermes_home (str): The active HERMES_HOME directory path. Use this
for profile-scoped storage instead of hardcoding ``~/.hermes``.
- platform (str): "cli", "telegram", "discord", "cron", etc.
kwargs may also include:
- agent_context (str): "primary", "subagent", "cron", or "flush".
Providers should skip writes for non-primary contexts (cron system
prompts would corrupt user representations).
- agent_identity (str): Profile name (e.g. "coder"). Use for
per-profile provider identity scoping.
- agent_workspace (str): Shared workspace name (e.g. "hermes").
- parent_session_id (str): For subagents, the parent's session_id.
- user_id (str): Platform user identifier (gateway sessions).
"""
def system_prompt_block(self) -> str:
"""Return text to include in the system prompt.
Called during system prompt assembly. Return empty string to skip.
This is for STATIC provider info (instructions, status). Prefetched
recall context is injected separately via prefetch().
"""
return ""
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Recall relevant context for the upcoming turn.
Called before each API call. Return formatted text to inject as
context, or empty string if nothing relevant. Implementations
should be fast use background threads for the actual recall
and return cached results here.
session_id is provided for providers serving concurrent sessions
(gateway group chats, cached agents). Providers that don't need
per-session scoping can ignore it.
"""
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""Queue a background recall for the NEXT turn.
Called after each turn completes. The result will be consumed
by prefetch() on the next turn. Default is no-op providers
that do background prefetching should override this.
"""
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Persist a completed turn to the backend.
Called after each turn. Should be non-blocking queue for
background processing if the backend has latency.
"""
@abstractmethod
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return tool schemas this provider exposes.
Each schema follows the OpenAI function calling format:
{"name": "...", "description": "...", "parameters": {...}}
Return empty list if this provider has no tools (context-only).
"""
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
"""Handle a tool call for one of this provider's tools.
Must return a JSON string (the tool result).
Only called for tool names returned by get_tool_schemas().
"""
raise NotImplementedError(f"Provider {self.name} does not handle tool {tool_name}")
def shutdown(self) -> None:
"""Clean shutdown — flush queues, close connections."""
# -- Optional hooks (override to opt in) ---------------------------------
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
"""Called at the start of each turn with the user message.
Use for turn-counting, scope management, periodic maintenance.
kwargs may include: remaining_tokens, model, platform, tool_count.
Providers use what they need; extras are ignored.
"""
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""Called when a session ends (explicit exit or timeout).
Use for end-of-session fact extraction, summarization, etc.
messages is the full conversation history.
NOT called after every turn only at actual session boundaries
(CLI exit, /reset, gateway session expiry).
"""
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Called before context compression discards old messages.
Use to extract insights from messages about to be compressed.
messages is the list that will be summarized/discarded.
Return text to include in the compression summary prompt so the
compressor preserves provider-extracted insights. Return empty
string for no contribution (backwards-compatible default).
"""
return ""
def on_delegation(self, task: str, result: str, *,
child_session_id: str = "", **kwargs) -> None:
"""Called on the PARENT agent when a subagent completes.
The parent's memory provider gets the task+result pair as an
observation of what was delegated and what came back. The subagent
itself has no provider session (skip_memory=True).
task: the delegation prompt
result: the subagent's final response
child_session_id: the subagent's session_id
"""
def get_config_schema(self) -> List[Dict[str, Any]]:
"""Return config fields this provider needs for setup.
Used by 'hermes memory setup' to walk the user through configuration.
Each field is a dict with:
key: config key name (e.g. 'api_key', 'mode')
description: human-readable description
secret: True if this should go to .env (default: False)
required: True if required (default: False)
default: default value (optional)
choices: list of valid values (optional)
url: URL where user can get this credential (optional)
env_var: explicit env var name for secrets (default: auto-generated)
Return empty list if no config needed (e.g. local-only providers).
"""
return []
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
"""Write non-secret config to the provider's native location.
Called by 'hermes memory setup' after collecting user inputs.
``values`` contains only non-secret fields (secrets go to .env).
``hermes_home`` is the active HERMES_HOME directory path.
Providers with native config files (JSON, YAML) should override
this to write to their expected location. Providers that use only
env vars can leave the default (no-op).
All new memory provider plugins MUST implement either:
- save_config() for native config file formats, OR
- use only env vars (in which case get_config_schema() fields
should all have ``env_var`` set and this method stays no-op).
"""
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Called when the built-in memory tool writes an entry.
action: 'add', 'replace', or 'remove'
target: 'memory' or 'user'
content: the entry content
Use to mirror built-in memory writes to your backend.
"""
+12 -2
View File
@@ -24,10 +24,11 @@ logger = logging.getLogger(__name__)
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
"gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
"custom", "local",
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "claude", "deep-seek",
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
@@ -101,6 +102,11 @@ DEFAULT_CONTEXT_LENGTHS = {
"gpt-4": 128000,
# Google
"gemini": 1048576,
# Gemma (open models served via AI Studio)
"gemma-4-31b": 256000,
"gemma-4-26b": 256000,
"gemma-3": 131072,
"gemma": 8192, # fallback for older gemma models
# DeepSeek
"deepseek": 128000,
# Meta
@@ -113,6 +119,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"glm": 202752,
# Kimi
"kimi": 262144,
# Arcee
"trinity": 262144,
# Hugging Face Inference Providers — model IDs use org/name format
"Qwen/Qwen3.5-397B-A17B": 131072,
"Qwen/Qwen3.5-35B-A3B": 131072,
@@ -121,6 +129,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"moonshotai/Kimi-K2-Thinking": 262144,
"MiniMaxAI/MiniMax-M2.5": 204800,
"XiaomiMiMo/MiMo-V2-Flash": 32768,
"mimo-v2-pro": 1048576,
"mimo-v2-omni": 1048576,
"zai-org/GLM-5": 202752,
}
@@ -171,7 +181,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"dashscope.aliyuncs.com": "alibaba",
"dashscope-intl.aliyuncs.com": "alibaba",
"openrouter.ai": "openrouter",
"generativelanguage.googleapis.com": "google",
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
"api.githubcopilot.com": "copilot",
+617 -8
View File
@@ -1,19 +1,31 @@
"""Models.dev registry integration for provider-aware context length detection.
"""Models.dev registry integration — primary database for providers and models.
Fetches model metadata from https://models.dev/api.json a community-maintained
database of 3800+ models across 100+ providers, including per-provider context
windows, pricing, and capabilities.
Fetches from https://models.dev/api.json a community-maintained database
of 4000+ models across 109+ providers. Provides:
Data is cached in memory (1hr TTL) and on disk (~/.hermes/models_dev_cache.json)
to avoid cold-start network latency.
- **Provider metadata**: name, base URL, env vars, documentation link
- **Model metadata**: context window, max output, cost/M tokens, capabilities
(reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff,
open-weights flag, family grouping, deprecation status
Data resolution order (like TypeScript OpenCode):
1. Bundled snapshot (ships with the package offline-first)
2. Disk cache (~/.hermes/models_dev_cache.json)
3. Network fetch (https://models.dev/api.json)
4. Background refresh every 60 minutes
Other modules should import the dataclasses and query functions from here
rather than parsing the raw JSON themselves.
"""
import difflib
import json
import logging
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, Tuple, Union
from utils import atomic_json_write
@@ -28,7 +40,110 @@ _MODELS_DEV_CACHE_TTL = 3600 # 1 hour in-memory
_models_dev_cache: Dict[str, Any] = {}
_models_dev_cache_time: float = 0
# Provider ID mapping: Hermes provider names → models.dev provider IDs
# ---------------------------------------------------------------------------
# Dataclasses — rich metadata for providers and models
# ---------------------------------------------------------------------------
@dataclass
class ModelInfo:
"""Full metadata for a single model from models.dev."""
id: str
name: str
family: str
provider_id: str # models.dev provider ID (e.g. "anthropic")
# Capabilities
reasoning: bool = False
tool_call: bool = False
attachment: bool = False # supports image/file attachments (vision)
temperature: bool = False
structured_output: bool = False
open_weights: bool = False
# Modalities
input_modalities: Tuple[str, ...] = () # ("text", "image", "pdf", ...)
output_modalities: Tuple[str, ...] = ()
# Limits
context_window: int = 0
max_output: int = 0
max_input: Optional[int] = None
# Cost (per million tokens, USD)
cost_input: float = 0.0
cost_output: float = 0.0
cost_cache_read: Optional[float] = None
cost_cache_write: Optional[float] = None
# Metadata
knowledge_cutoff: str = ""
release_date: str = ""
status: str = "" # "alpha", "beta", "deprecated", or ""
interleaved: Any = False # True or {"field": "reasoning_content"}
def has_cost_data(self) -> bool:
return self.cost_input > 0 or self.cost_output > 0
def supports_vision(self) -> bool:
return self.attachment or "image" in self.input_modalities
def supports_pdf(self) -> bool:
return "pdf" in self.input_modalities
def supports_audio_input(self) -> bool:
return "audio" in self.input_modalities
def format_cost(self) -> str:
"""Human-readable cost string, e.g. '$3.00/M in, $15.00/M out'."""
if not self.has_cost_data():
return "unknown"
parts = [f"${self.cost_input:.2f}/M in", f"${self.cost_output:.2f}/M out"]
if self.cost_cache_read is not None:
parts.append(f"cache read ${self.cost_cache_read:.2f}/M")
return ", ".join(parts)
def format_capabilities(self) -> str:
"""Human-readable capabilities, e.g. 'reasoning, tools, vision, PDF'."""
caps = []
if self.reasoning:
caps.append("reasoning")
if self.tool_call:
caps.append("tools")
if self.supports_vision():
caps.append("vision")
if self.supports_pdf():
caps.append("PDF")
if self.supports_audio_input():
caps.append("audio")
if self.structured_output:
caps.append("structured output")
if self.open_weights:
caps.append("open weights")
return ", ".join(caps) if caps else "basic"
@dataclass
class ProviderInfo:
"""Full metadata for a provider from models.dev."""
id: str # models.dev provider ID
name: str # display name
env: Tuple[str, ...] # env var names for API key
api: str # base URL
doc: str = "" # documentation URL
model_count: int = 0
def has_api_url(self) -> bool:
return bool(self.api)
# ---------------------------------------------------------------------------
# Provider ID mapping: Hermes ↔ models.dev
# ---------------------------------------------------------------------------
# Hermes provider names → models.dev provider IDs
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"openrouter": "openrouter",
"anthropic": "anthropic",
@@ -44,8 +159,29 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
"opencode-go": "opencode-go",
"kilocode": "kilo",
"fireworks": "fireworks-ai",
"huggingface": "huggingface",
"gemini": "google",
"google": "google",
"xai": "xai",
"nvidia": "nvidia",
"groq": "groq",
"mistral": "mistral",
"togetherai": "togetherai",
"perplexity": "perplexity",
"cohere": "cohere",
}
# Reverse mapping: models.dev → Hermes (built lazily)
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None
def _get_reverse_mapping() -> Dict[str, str]:
"""Return models.dev ID → Hermes provider ID mapping."""
global _MODELS_DEV_TO_PROVIDER
if _MODELS_DEV_TO_PROVIDER is None:
_MODELS_DEV_TO_PROVIDER = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}
return _MODELS_DEV_TO_PROVIDER
def _get_cache_path() -> Path:
"""Return path to disk cache file."""
@@ -170,3 +306,476 @@ def _extract_context(entry: Dict[str, Any]) -> Optional[int]:
if isinstance(ctx, (int, float)) and ctx > 0:
return int(ctx)
return None
# ---------------------------------------------------------------------------
# Model capability metadata
# ---------------------------------------------------------------------------
@dataclass
class ModelCapabilities:
"""Structured capability metadata for a model from models.dev."""
supports_tools: bool = True
supports_vision: bool = False
supports_reasoning: bool = False
context_window: int = 200000
max_output_tokens: int = 8192
model_family: str = ""
def _get_provider_models(provider: str) -> Optional[Dict[str, Any]]:
"""Resolve a Hermes provider ID to its models dict from models.dev.
Returns the models dict or None if the provider is unknown or has no data.
"""
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
if not mdev_provider_id:
return None
data = fetch_models_dev()
provider_data = data.get(mdev_provider_id)
if not isinstance(provider_data, dict):
return None
models = provider_data.get("models", {})
if not isinstance(models, dict):
return None
return models
def _find_model_entry(models: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]:
"""Find a model entry by exact match, then case-insensitive fallback."""
# Exact match
entry = models.get(model)
if isinstance(entry, dict):
return entry
# Case-insensitive match
model_lower = model.lower()
for mid, mdata in models.items():
if mid.lower() == model_lower and isinstance(mdata, dict):
return mdata
return None
def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilities]:
"""Look up full capability metadata from models.dev cache.
Uses the existing fetch_models_dev() and PROVIDER_TO_MODELS_DEV mapping.
Returns None if model not found.
Extracts from model entry fields:
- reasoning (bool) supports_reasoning
- tool_call (bool) supports_tools
- attachment (bool) supports_vision
- limit.context (int) context_window
- limit.output (int) max_output_tokens
- family (str) model_family
"""
models = _get_provider_models(provider)
if models is None:
return None
entry = _find_model_entry(models, model)
if entry is None:
return None
# Extract capability flags (default to False if missing)
supports_tools = bool(entry.get("tool_call", False))
supports_vision = bool(entry.get("attachment", False))
supports_reasoning = bool(entry.get("reasoning", False))
# Extract limits
limit = entry.get("limit", {})
if not isinstance(limit, dict):
limit = {}
ctx = limit.get("context")
context_window = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 200000
out = limit.get("output")
max_output_tokens = int(out) if isinstance(out, (int, float)) and out > 0 else 8192
model_family = entry.get("family", "") or ""
return ModelCapabilities(
supports_tools=supports_tools,
supports_vision=supports_vision,
supports_reasoning=supports_reasoning,
context_window=context_window,
max_output_tokens=max_output_tokens,
model_family=model_family,
)
def list_provider_models(provider: str) -> List[str]:
"""Return all model IDs for a provider from models.dev.
Returns an empty list if the provider is unknown or has no data.
"""
models = _get_provider_models(provider)
if models is None:
return []
return list(models.keys())
# Patterns that indicate non-agentic or noise models (TTS, embedding,
# dated preview snapshots, live/streaming-only, image-only).
import re
_NOISE_PATTERNS: re.Pattern = re.compile(
r"-tts\b|embedding|live-|-(preview|exp)-\d{2,4}[-_]|"
r"-image\b|-image-preview\b|-customtools\b",
re.IGNORECASE,
)
def list_agentic_models(provider: str) -> List[str]:
"""Return model IDs suitable for agentic use from models.dev.
Filters for tool_call=True and excludes noise (TTS, embedding,
dated preview snapshots, live/streaming, image-only models).
Returns an empty list on any failure.
"""
models = _get_provider_models(provider)
if models is None:
return []
result = []
for mid, entry in models.items():
if not isinstance(entry, dict):
continue
if not entry.get("tool_call", False):
continue
if _NOISE_PATTERNS.search(mid):
continue
result.append(mid)
return result
def search_models_dev(
query: str, provider: str = None, limit: int = 5
) -> List[Dict[str, Any]]:
"""Fuzzy search across models.dev catalog. Returns matching model entries.
Args:
query: Search string to match against model IDs.
provider: Optional Hermes provider ID to restrict search scope.
If None, searches across all providers in PROVIDER_TO_MODELS_DEV.
limit: Maximum number of results to return.
Returns:
List of dicts, each containing 'provider', 'model_id', and the full
model 'entry' from models.dev.
"""
data = fetch_models_dev()
if not data:
return []
# Build list of (provider_id, model_id, entry) candidates
candidates: List[tuple] = []
if provider is not None:
# Search only the specified provider
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
if not mdev_provider_id:
return []
provider_data = data.get(mdev_provider_id, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((provider, mid, mdata))
else:
# Search across all mapped providers
for hermes_prov, mdev_prov in PROVIDER_TO_MODELS_DEV.items():
provider_data = data.get(mdev_prov, {})
if isinstance(provider_data, dict):
models = provider_data.get("models", {})
if isinstance(models, dict):
for mid, mdata in models.items():
candidates.append((hermes_prov, mid, mdata))
if not candidates:
return []
# Use difflib for fuzzy matching — case-insensitive comparison
model_ids_lower = [c[1].lower() for c in candidates]
query_lower = query.lower()
# First try exact substring matches (more intuitive than pure edit-distance)
substring_matches = []
for prov, mid, mdata in candidates:
if query_lower in mid.lower():
substring_matches.append({"provider": prov, "model_id": mid, "entry": mdata})
# Then add difflib fuzzy matches for any remaining slots
fuzzy_ids = difflib.get_close_matches(
query_lower, model_ids_lower, n=limit * 2, cutoff=0.4
)
seen_ids: set = set()
results: List[Dict[str, Any]] = []
# Prioritize substring matches
for match in substring_matches:
key = (match["provider"], match["model_id"])
if key not in seen_ids:
seen_ids.add(key)
results.append(match)
if len(results) >= limit:
return results
# Add fuzzy matches
for fid in fuzzy_ids:
# Find original-case candidates matching this lowered ID
for prov, mid, mdata in candidates:
if mid.lower() == fid:
key = (prov, mid)
if key not in seen_ids:
seen_ids.add(key)
results.append({"provider": prov, "model_id": mid, "entry": mdata})
if len(results) >= limit:
return results
return results
# ---------------------------------------------------------------------------
# Rich dataclass constructors — parse raw models.dev JSON into dataclasses
# ---------------------------------------------------------------------------
def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> ModelInfo:
"""Convert a raw models.dev model entry dict into a ModelInfo dataclass."""
limit = raw.get("limit") or {}
if not isinstance(limit, dict):
limit = {}
cost = raw.get("cost") or {}
if not isinstance(cost, dict):
cost = {}
modalities = raw.get("modalities") or {}
if not isinstance(modalities, dict):
modalities = {}
input_mods = modalities.get("input") or []
output_mods = modalities.get("output") or []
ctx = limit.get("context")
ctx_int = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 0
out = limit.get("output")
out_int = int(out) if isinstance(out, (int, float)) and out > 0 else 0
inp = limit.get("input")
inp_int = int(inp) if isinstance(inp, (int, float)) and inp > 0 else None
return ModelInfo(
id=model_id,
name=raw.get("name", "") or model_id,
family=raw.get("family", "") or "",
provider_id=provider_id,
reasoning=bool(raw.get("reasoning", False)),
tool_call=bool(raw.get("tool_call", False)),
attachment=bool(raw.get("attachment", False)),
temperature=bool(raw.get("temperature", False)),
structured_output=bool(raw.get("structured_output", False)),
open_weights=bool(raw.get("open_weights", False)),
input_modalities=tuple(input_mods) if isinstance(input_mods, list) else (),
output_modalities=tuple(output_mods) if isinstance(output_mods, list) else (),
context_window=ctx_int,
max_output=out_int,
max_input=inp_int,
cost_input=float(cost.get("input", 0) or 0),
cost_output=float(cost.get("output", 0) or 0),
cost_cache_read=float(cost["cache_read"]) if "cache_read" in cost and cost["cache_read"] is not None else None,
cost_cache_write=float(cost["cache_write"]) if "cache_write" in cost and cost["cache_write"] is not None else None,
knowledge_cutoff=raw.get("knowledge", "") or "",
release_date=raw.get("release_date", "") or "",
status=raw.get("status", "") or "",
interleaved=raw.get("interleaved", False),
)
def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo:
"""Convert a raw models.dev provider entry dict into a ProviderInfo."""
env = raw.get("env") or []
models = raw.get("models") or {}
return ProviderInfo(
id=provider_id,
name=raw.get("name", "") or provider_id,
env=tuple(env) if isinstance(env, list) else (),
api=raw.get("api", "") or "",
doc=raw.get("doc", "") or "",
model_count=len(models) if isinstance(models, dict) else 0,
)
# ---------------------------------------------------------------------------
# Provider-level queries
# ---------------------------------------------------------------------------
def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
"""Get full provider metadata from models.dev.
Accepts either a Hermes provider ID (e.g. "kilocode") or a models.dev
ID (e.g. "kilo"). Returns None if the provider is not in the catalog.
"""
# Resolve Hermes ID → models.dev ID
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
data = fetch_models_dev()
raw = data.get(mdev_id)
if not isinstance(raw, dict):
return None
return _parse_provider_info(mdev_id, raw)
def list_all_providers() -> Dict[str, ProviderInfo]:
"""Return all providers from models.dev as {provider_id: ProviderInfo}.
Returns the full catalog 109+ providers. For providers that have
a Hermes alias, both the models.dev ID and the Hermes ID are included.
"""
data = fetch_models_dev()
result: Dict[str, ProviderInfo] = {}
for pid, pdata in data.items():
if isinstance(pdata, dict):
info = _parse_provider_info(pid, pdata)
result[pid] = info
return result
def get_providers_for_env_var(env_var: str) -> List[str]:
"""Reverse lookup: find all providers that use a given env var.
Useful for auto-detection: "user has ANTHROPIC_API_KEY set, which
providers does that enable?"
Returns list of models.dev provider IDs.
"""
data = fetch_models_dev()
matches: List[str] = []
for pid, pdata in data.items():
if isinstance(pdata, dict):
env = pdata.get("env", [])
if isinstance(env, list) and env_var in env:
matches.append(pid)
return matches
# ---------------------------------------------------------------------------
# Model-level queries (rich ModelInfo)
# ---------------------------------------------------------------------------
def get_model_info(
provider_id: str, model_id: str
) -> Optional[ModelInfo]:
"""Get full model metadata from models.dev.
Accepts Hermes or models.dev provider ID. Tries exact match then
case-insensitive fallback. Returns None if not found.
"""
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
data = fetch_models_dev()
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
return None
models = pdata.get("models", {})
if not isinstance(models, dict):
return None
# Exact match
raw = models.get(model_id)
if isinstance(raw, dict):
return _parse_model_info(model_id, raw, mdev_id)
# Case-insensitive fallback
model_lower = model_id.lower()
for mid, mdata in models.items():
if mid.lower() == model_lower and isinstance(mdata, dict):
return _parse_model_info(mid, mdata, mdev_id)
return None
def get_model_info_any_provider(model_id: str) -> Optional[ModelInfo]:
"""Search all providers for a model by ID.
Useful when you have a full slug like "anthropic/claude-sonnet-4.6" or
a bare name and want to find it anywhere. Checks Hermes-mapped providers
first, then falls back to all models.dev providers.
"""
data = fetch_models_dev()
# Try Hermes-mapped providers first (more likely what the user wants)
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
continue
models = pdata.get("models", {})
if not isinstance(models, dict):
continue
raw = models.get(model_id)
if isinstance(raw, dict):
return _parse_model_info(model_id, raw, mdev_id)
# Case-insensitive
model_lower = model_id.lower()
for mid, mdata in models.items():
if mid.lower() == model_lower and isinstance(mdata, dict):
return _parse_model_info(mid, mdata, mdev_id)
# Fall back to ALL providers
for pid, pdata in data.items():
if pid in _get_reverse_mapping():
continue # already checked
if not isinstance(pdata, dict):
continue
models = pdata.get("models", {})
if not isinstance(models, dict):
continue
raw = models.get(model_id)
if isinstance(raw, dict):
return _parse_model_info(model_id, raw, pid)
return None
def list_provider_model_infos(provider_id: str) -> List[ModelInfo]:
"""Return all models for a provider as ModelInfo objects.
Filters out deprecated models by default.
"""
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
data = fetch_models_dev()
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
return []
models = pdata.get("models", {})
if not isinstance(models, dict):
return []
result: List[ModelInfo] = []
for mid, mdata in models.items():
if not isinstance(mdata, dict):
continue
status = mdata.get("status", "")
if status == "deprecated":
continue
result.append(_parse_model_info(mid, mdata, mdev_id))
return result
+138 -1
View File
@@ -187,7 +187,69 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
# Model name substrings that trigger tool-use enforcement guidance.
# Add new patterns here when a model family needs explicit steering.
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex")
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok")
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification.
# Inspired by patterns from OpenAI's GPT-5.4 prompting guide & OpenClaw PR #38953.
OPENAI_MODEL_EXECUTION_GUIDANCE = (
"# Execution discipline\n"
"<tool_persistence>\n"
"- Use tools whenever they improve correctness, completeness, or grounding.\n"
"- Do not stop early when another tool call would materially improve the result.\n"
"- If a tool returns empty or partial results, retry with a different query or "
"strategy before giving up.\n"
"- Keep calling tools until: (1) the task is complete, AND (2) you have verified "
"the result.\n"
"</tool_persistence>\n"
"\n"
"<prerequisite_checks>\n"
"- Before taking an action, check whether prerequisite discovery, lookup, or "
"context-gathering steps are needed.\n"
"- Do not skip prerequisite steps just because the final action seems obvious.\n"
"- If a task depends on output from a prior step, resolve that dependency first.\n"
"</prerequisite_checks>\n"
"\n"
"<verification>\n"
"Before finalizing your response:\n"
"- Correctness: does the output satisfy every stated requirement?\n"
"- Grounding: are factual claims backed by tool outputs or provided context?\n"
"- Formatting: does the output match the requested format or schema?\n"
"- Safety: if the next step has side effects (file writes, commands, API calls), "
"confirm scope before executing.\n"
"</verification>\n"
"\n"
"<missing_context>\n"
"- If required context is missing, do NOT guess or hallucinate an answer.\n"
"- Use the appropriate lookup tool when missing information is retrievable "
"(search_files, web_search, read_file, etc.).\n"
"- Ask a clarifying question only when the information cannot be retrieved by tools.\n"
"- If you must proceed with incomplete information, label assumptions explicitly.\n"
"</missing_context>"
)
# Gemini/Gemma-specific operational guidance, adapted from OpenCode's gemini.txt.
# Injected alongside TOOL_USE_ENFORCEMENT_GUIDANCE when the model is Gemini or Gemma.
GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
"# Google model operational directives\n"
"Follow these operational rules strictly:\n"
"- **Absolute paths:** Always construct and use absolute file paths for all "
"file system operations. Combine the project root with relative paths.\n"
"- **Verify first:** Use read_file/search_files to check file contents and "
"project structure before making changes. Never guess at file contents.\n"
"- **Dependency checks:** Never assume a library is available. Check "
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
"paragraphs. Focus on actions and results over narration.\n"
"- **Parallel tool calls:** When you need to perform multiple independent "
"operations (e.g. reading several files), make all the tool calls in a "
"single response rather than sequentially.\n"
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
"to prevent CLI tools from hanging on prompts.\n"
"- **Keep going:** Work autonomously until the task is fully resolved. "
"Don't stop with a plan — execute it.\n"
)
# Model name substrings that should use the 'developer' role instead of
# 'system' for the system prompt. OpenAI's newer models (GPT-5, Codex)
@@ -466,11 +528,19 @@ def build_skills_system_prompt(
return ""
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or os.environ.get("HERMES_SESSION_PLATFORM")
or ""
)
cache_key = (
str(skills_dir.resolve()),
tuple(str(d) for d in external_dirs),
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
_platform_hint,
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
@@ -652,6 +722,73 @@ def build_skills_system_prompt(
return result
def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str:
"""Build a compact Nous subscription capability block for the system prompt."""
try:
from hermes_cli.nous_subscription import get_nous_subscription_features
from tools.tool_backend_helpers import managed_nous_tools_enabled
except Exception as exc:
logger.debug("Failed to import Nous subscription helper: %s", exc)
return ""
if not managed_nous_tools_enabled():
return ""
valid_names = set(valid_tool_names or set())
relevant_tool_names = {
"web_search",
"web_extract",
"browser_navigate",
"browser_snapshot",
"browser_click",
"browser_type",
"browser_scroll",
"browser_console",
"browser_close",
"browser_press",
"browser_get_images",
"browser_vision",
"image_generate",
"text_to_speech",
"terminal",
"process",
"execute_code",
}
if valid_names and not (valid_names & relevant_tool_names):
return ""
features = get_nous_subscription_features()
def _status_line(feature) -> str:
if feature.managed_by_nous:
return f"- {feature.label}: active via Nous subscription"
if feature.active:
current = feature.current_provider or "configured provider"
return f"- {feature.label}: currently using {current}"
if feature.included_by_default and features.nous_auth_present:
return f"- {feature.label}: included with Nous subscription, not currently selected"
if feature.key == "modal" and features.nous_auth_present:
return f"- {feature.label}: optional via Nous subscription"
return f"- {feature.label}: not currently available"
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browserbase) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
]
)
return "\n".join(lines)
# =========================================================================
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
+7 -2
View File
@@ -48,13 +48,18 @@ _PREFIX_PATTERNS = [
r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash)
r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key
r"exa_[A-Za-z0-9]{10,}", # Exa search API key
r"gsk_[A-Za-z0-9]{10,}", # Groq Cloud API key
r"syt_[A-Za-z0-9]{10,}", # Matrix access token
r"retaindb_[A-Za-z0-9]{10,}", # RetainDB API key
r"hsk-[A-Za-z0-9]{10,}", # Hindsight API key
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
]
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
_SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)"
_ENV_ASSIGN_RE = re.compile(
rf"([A-Z_]*{_SECRET_ENV_NAMES}[A-Z_]*)\s*=\s*(['\"]?)(\S+)\2",
re.IGNORECASE,
rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2",
)
# JSON field patterns: "apiKey": "value", "token": "value", etc.
+71
View File
@@ -16,6 +16,9 @@ logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {}
_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+")
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
def build_plan_path(
@@ -76,6 +79,45 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
return loaded_skill, skill_dir, skill_name
def _inject_skill_config(loaded_skill: dict[str, Any], parts: list[str]) -> None:
"""Resolve and inject skill-declared config values into the message parts.
If the loaded skill's frontmatter declares ``metadata.hermes.config``
entries, their current values (from config.yaml or defaults) are appended
as a ``[Skill config: ...]`` block so the agent knows the configured values
without needing to read config.yaml itself.
"""
try:
from agent.skill_utils import (
extract_skill_config_vars,
parse_frontmatter,
resolve_skill_config_values,
)
# The loaded_skill dict contains the raw content which includes frontmatter
raw_content = str(loaded_skill.get("raw_content") or loaded_skill.get("content") or "")
if not raw_content:
return
frontmatter, _ = parse_frontmatter(raw_content)
config_vars = extract_skill_config_vars(frontmatter)
if not config_vars:
return
resolved = resolve_skill_config_values(config_vars)
if not resolved:
return
lines = ["", "[Skill config (from ~/.hermes/config.yaml):"]
for key, value in resolved.items():
display_val = str(value) if value else "(not set)"
lines.append(f" {key} = {display_val}")
lines.append("]")
parts.extend(lines)
except Exception:
pass # Non-critical — skill still loads without config injection
def _build_skill_message(
loaded_skill: dict[str, Any],
skill_dir: Path | None,
@@ -90,6 +132,9 @@ def _build_skill_message(
parts = [activation_note, "", content.strip()]
# ── Inject resolved skill config values ──
_inject_skill_config(loaded_skill, parts)
if loaded_skill.get("setup_skipped"):
parts.extend(
[
@@ -196,7 +241,14 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
description = line[:80]
break
seen_names.add(name)
# Normalize to hyphen-separated slug, stripping
# non-alnum chars (e.g. +, /) to avoid invalid
# Telegram command names downstream.
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
cmd_name = _SKILL_INVALID_CHARS.sub('', cmd_name)
cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')
if not cmd_name:
continue
_skill_commands[f"/{cmd_name}"] = {
"name": name,
"description": description or f"Invoke the {name} skill",
@@ -217,6 +269,25 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
return _skill_commands
def resolve_skill_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed /command to its canonical skill_cmds key.
Skills are always stored with hyphens ``scan_skill_commands`` normalizes
spaces and underscores to hyphens when building the key. Hyphens and
underscores are treated interchangeably in user input: this matches
``_check_unavailable_skill`` and accommodates Telegram bot-command names
(which disallow hyphens, so ``/claude-code`` is registered as
``/claude_code`` and comes back in the underscored form).
Returns the matching ``/slug`` key from ``get_skill_commands()`` or
``None`` if no match.
"""
if not command:
return None
cmd_key = f"/{command.replace('_', '-')}"
return cmd_key if cmd_key in get_skill_commands() else None
def build_skill_invocation_message(
cmd_key: str,
user_instruction: str = "",
+171 -5
View File
@@ -118,12 +118,17 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
# ── Disabled skills ───────────────────────────────────────────────────────
def get_disabled_skill_names() -> Set[str]:
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
"""Read disabled skill names from config.yaml.
Resolves platform from ``HERMES_PLATFORM`` env var, falls back to
the global disabled list. Reads the config file directly (no CLI
config imports) to stay lightweight.
Args:
platform: Explicit platform name (e.g. ``"telegram"``). When
*None*, resolves from ``HERMES_PLATFORM`` or
``HERMES_SESSION_PLATFORM`` env vars. Falls back to the
global disabled list when no platform is determined.
Reads the config file directly (no CLI config imports) to stay
lightweight.
"""
config_path = get_hermes_home() / "config.yaml"
if not config_path.exists():
@@ -140,7 +145,11 @@ def get_disabled_skill_names() -> Set[str]:
if not isinstance(skills_cfg, dict):
return set()
resolved_platform = os.getenv("HERMES_PLATFORM")
resolved_platform = (
platform
or os.getenv("HERMES_PLATFORM")
or os.getenv("HERMES_SESSION_PLATFORM")
)
if resolved_platform:
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
resolved_platform
@@ -245,6 +254,163 @@ def extract_skill_conditions(frontmatter: Dict[str, Any]) -> Dict[str, List]:
}
# ── Skill config extraction ───────────────────────────────────────────────
def extract_skill_config_vars(frontmatter: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract config variable declarations from parsed frontmatter.
Skills declare config.yaml settings they need via::
metadata:
hermes:
config:
- key: wiki.path
description: Path to the LLM Wiki knowledge base directory
default: "~/wiki"
prompt: Wiki directory path
Returns a list of dicts with keys: ``key``, ``description``, ``default``,
``prompt``. Invalid or incomplete entries are silently skipped.
"""
metadata = frontmatter.get("metadata")
if not isinstance(metadata, dict):
return []
hermes = metadata.get("hermes")
if not isinstance(hermes, dict):
return []
raw = hermes.get("config")
if not raw:
return []
if isinstance(raw, dict):
raw = [raw]
if not isinstance(raw, list):
return []
result: List[Dict[str, Any]] = []
seen: set = set()
for item in raw:
if not isinstance(item, dict):
continue
key = str(item.get("key", "")).strip()
if not key or key in seen:
continue
# Must have at least key and description
desc = str(item.get("description", "")).strip()
if not desc:
continue
entry: Dict[str, Any] = {
"key": key,
"description": desc,
}
default = item.get("default")
if default is not None:
entry["default"] = default
prompt_text = item.get("prompt")
if isinstance(prompt_text, str) and prompt_text.strip():
entry["prompt"] = prompt_text.strip()
else:
entry["prompt"] = desc
seen.add(key)
result.append(entry)
return result
def discover_all_skill_config_vars() -> List[Dict[str, Any]]:
"""Scan all enabled skills and collect their config variable declarations.
Walks every skills directory, parses each SKILL.md frontmatter, and returns
a deduplicated list of config var dicts. Each dict also includes a
``skill`` key with the skill name for attribution.
Disabled and platform-incompatible skills are excluded.
"""
all_vars: List[Dict[str, Any]] = []
seen_keys: set = set()
disabled = get_disabled_skill_names()
for skills_dir in get_all_skills_dirs():
if not skills_dir.is_dir():
continue
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
try:
raw = skill_file.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(raw)
except Exception:
continue
skill_name = frontmatter.get("name") or skill_file.parent.name
if str(skill_name) in disabled:
continue
if not skill_matches_platform(frontmatter):
continue
config_vars = extract_skill_config_vars(frontmatter)
for var in config_vars:
if var["key"] not in seen_keys:
var["skill"] = str(skill_name)
all_vars.append(var)
seen_keys.add(var["key"])
return all_vars
# Storage prefix: all skill config vars are stored under skills.config.*
# in config.yaml. Skill authors declare logical keys (e.g. "wiki.path");
# the system adds this prefix for storage and strips it for display.
SKILL_CONFIG_PREFIX = "skills.config"
def _resolve_dotpath(config: Dict[str, Any], dotted_key: str):
"""Walk a nested dict following a dotted key. Returns None if any part is missing."""
parts = dotted_key.split(".")
current = config
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def resolve_skill_config_values(
config_vars: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Resolve current values for skill config vars from config.yaml.
Skill config is stored under ``skills.config.<key>`` in config.yaml.
Returns a dict mapping **logical** keys (as declared by skills) to their
current values (or the declared default if the key isn't set).
Path values are expanded via ``os.path.expanduser``.
"""
config_path = get_hermes_home() / "config.yaml"
config: Dict[str, Any] = {}
if config_path.exists():
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
if isinstance(parsed, dict):
config = parsed
except Exception:
pass
resolved: Dict[str, Any] = {}
for var in config_vars:
logical_key = var["key"]
storage_key = f"{SKILL_CONFIG_PREFIX}.{logical_key}"
value = _resolve_dotpath(config, storage_key)
if value is None or (isinstance(value, str) and not value.strip()):
value = var.get("default", "")
# Expand ~ in path-like values
if isinstance(value, str) and ("~" in value or "${" in value):
value = os.path.expanduser(os.path.expandvars(value))
resolved[logical_key] = value
return resolved
# ── Description extraction ────────────────────────────────────────────────
+3 -7
View File
@@ -6,6 +6,8 @@ import os
import re
from typing import Any, Dict, Optional
from utils import is_truthy_value
_COMPLEX_KEYWORDS = {
"debug",
"debugging",
@@ -47,13 +49,7 @@ _URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
def _coerce_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
return is_truthy_value(value, default=default)
def _coerce_int(value: Any, default: int) -> int:
+219
View File
@@ -0,0 +1,219 @@
"""Progressive subdirectory hint discovery.
As the agent navigates into subdirectories via tool calls (read_file, terminal,
search_files, etc.), this module discovers and loads project context files
(AGENTS.md, CLAUDE.md, .cursorrules) from those directories. Discovered hints
are appended to the tool result so the model gets relevant context at the moment
it starts working in a new area of the codebase.
This complements the startup context loading in ``prompt_builder.py`` which only
loads from the CWD. Subdirectory hints are discovered lazily and injected into
the conversation without modifying the system prompt (preserving prompt caching).
Inspired by Block/goose's SubdirectoryHintTracker.
"""
import logging
import os
import re
import shlex
from pathlib import Path
from typing import Dict, Any, Optional, Set
from agent.prompt_builder import _scan_context_content
logger = logging.getLogger(__name__)
# Context files to look for in subdirectories, in priority order.
# Same filenames as prompt_builder.py but we load ALL found (not first-wins)
# since different subdirectories may use different conventions.
_HINT_FILENAMES = [
"AGENTS.md", "agents.md",
"CLAUDE.md", "claude.md",
".cursorrules",
]
# Maximum chars per hint file to prevent context bloat
_MAX_HINT_CHARS = 8_000
# Tool argument keys that typically contain file paths
_PATH_ARG_KEYS = {"path", "file_path", "workdir"}
# Tools that take shell commands where we should extract paths
_COMMAND_TOOLS = {"terminal"}
# How many parent directories to walk up when looking for hints.
# Prevents scanning all the way to / for deeply nested paths.
_MAX_ANCESTOR_WALK = 5
class SubdirectoryHintTracker:
"""Track which directories the agent visits and load hints on first access.
Usage::
tracker = SubdirectoryHintTracker(working_dir="/path/to/project")
# After each tool call:
hints = tracker.check_tool_call("read_file", {"path": "backend/src/main.py"})
if hints:
tool_result += hints # append to the tool result string
"""
def __init__(self, working_dir: Optional[str] = None):
self.working_dir = Path(working_dir or os.getcwd()).resolve()
self._loaded_dirs: Set[Path] = set()
# Pre-mark the working dir as loaded (startup context handles it)
self._loaded_dirs.add(self.working_dir)
def check_tool_call(
self,
tool_name: str,
tool_args: Dict[str, Any],
) -> Optional[str]:
"""Check tool call arguments for new directories and load any hint files.
Returns formatted hint text to append to the tool result, or None.
"""
dirs = self._extract_directories(tool_name, tool_args)
if not dirs:
return None
all_hints = []
for d in dirs:
hints = self._load_hints_for_directory(d)
if hints:
all_hints.append(hints)
if not all_hints:
return None
return "\n\n" + "\n\n".join(all_hints)
def _extract_directories(
self, tool_name: str, args: Dict[str, Any]
) -> list:
"""Extract directory paths from tool call arguments."""
candidates: Set[Path] = set()
# Direct path arguments
for key in _PATH_ARG_KEYS:
val = args.get(key)
if isinstance(val, str) and val.strip():
self._add_path_candidate(val, candidates)
# Shell commands — extract path-like tokens
if tool_name in _COMMAND_TOOLS:
cmd = args.get("command", "")
if isinstance(cmd, str):
self._extract_paths_from_command(cmd, candidates)
return list(candidates)
def _add_path_candidate(self, raw_path: str, candidates: Set[Path]):
"""Resolve a raw path and add its directory + ancestors to candidates.
Walks up from the resolved directory toward the filesystem root,
stopping at the first directory already in ``_loaded_dirs`` (or after
``_MAX_ANCESTOR_WALK`` levels). This ensures that reading
``project/src/main.py`` discovers ``project/AGENTS.md`` even when
``project/src/`` has no hint files of its own.
"""
try:
p = Path(raw_path).expanduser()
if not p.is_absolute():
p = self.working_dir / p
p = p.resolve()
# Use parent if it's a file path (has extension or doesn't exist as dir)
if p.suffix or (p.exists() and p.is_file()):
p = p.parent
# Walk up ancestors — stop at already-loaded or root
for _ in range(_MAX_ANCESTOR_WALK):
if p in self._loaded_dirs:
break
if self._is_valid_subdir(p):
candidates.add(p)
parent = p.parent
if parent == p:
break # filesystem root
p = parent
except (OSError, ValueError):
pass
def _extract_paths_from_command(self, cmd: str, candidates: Set[Path]):
"""Extract path-like tokens from a shell command string."""
try:
tokens = shlex.split(cmd)
except ValueError:
tokens = cmd.split()
for token in tokens:
# Skip flags
if token.startswith("-"):
continue
# Must look like a path (contains / or .)
if "/" not in token and "." not in token:
continue
# Skip URLs
if token.startswith(("http://", "https://", "git@")):
continue
self._add_path_candidate(token, candidates)
def _is_valid_subdir(self, path: Path) -> bool:
"""Check if path is a valid directory to scan for hints."""
if not path.is_dir():
return False
if path in self._loaded_dirs:
return False
return True
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
"""Load hint files from a directory. Returns formatted text or None."""
self._loaded_dirs.add(directory)
found_hints = []
for filename in _HINT_FILENAMES:
hint_path = directory / filename
if not hint_path.is_file():
continue
try:
content = hint_path.read_text(encoding="utf-8").strip()
if not content:
continue
# Same security scan as startup context loading
content = _scan_context_content(content, filename)
if len(content) > _MAX_HINT_CHARS:
content = (
content[:_MAX_HINT_CHARS]
+ f"\n\n[...truncated {filename}: {len(content):,} chars total]"
)
# Best-effort relative path for display
rel_path = str(hint_path)
try:
rel_path = str(hint_path.relative_to(self.working_dir))
except ValueError:
try:
rel_path = str(hint_path.relative_to(Path.home()))
rel_path = "~/" + rel_path
except ValueError:
pass # keep absolute
found_hints.append((rel_path, content))
# First match wins per directory (like startup loading)
break
except Exception as exc:
logger.debug("Could not read %s: %s", hint_path, exc)
if not found_hints:
return None
sections = []
for rel_path, content in found_hints:
sections.append(
f"[Subdirectory context discovered: {rel_path}]\n{content}"
)
logger.debug(
"Loaded subdirectory hints from %s: %s",
directory,
[h[0] for h in found_hints],
)
return "\n\n".join(sections)
+33 -4
View File
@@ -18,7 +18,8 @@ model:
# "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY)
# "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex)
# "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN)
# "zai" - z.ai / ZhipuAI GLM (requires: GLM_API_KEY)
# "gemini" - Use Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY)
# "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY)
# "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY)
# "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
@@ -34,6 +35,12 @@ model:
# base_url: "http://localhost:1234/v1"
# No API key needed — local servers typically ignore auth.
#
# For Ollama Cloud (https://ollama.com/pricing):
# provider: "custom"
# base_url: "https://ollama.com/v1"
# Set OLLAMA_API_KEY in .env — automatically picked up when base_url
# points to ollama.com.
#
# Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.
provider: "auto"
@@ -309,7 +316,8 @@ compression:
# "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default)
# "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY)
# "nous" - Force Nous Portal (requires: hermes login)
# "codex" - Force Codex OAuth (requires: hermes model → Codex).
# "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY)
# "codex" - Force Codex OAuth (requires: hermes model → Codex).
# Uses gpt-5.3-codex which supports vision.
# "main" - Use your custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY).
# Works with OpenAI API, local models, or any OpenAI-compatible
@@ -539,7 +547,7 @@ platform_toolsets:
# skills_hub - skill_hub (search/install/manage from online registries — user-driven only)
# moa - mixture_of_agents (requires OPENROUTER_API_KEY)
# todo - todo (in-memory task planning, no deps)
# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI key)
# tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI/MINIMAX key)
# cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks)
# rl - rl_list_environments, rl_start_training, etc. (requires TINKER_API_KEY)
#
@@ -568,7 +576,7 @@ platform_toolsets:
# todo - Task planning and tracking for multi-step work
# memory - Persistent memory across sessions (personal notes + user profile)
# session_search - Search and recall past conversations (FTS5 + Gemini Flash summarization)
# tts - Text-to-speech (Edge TTS free, ElevenLabs, OpenAI)
# tts - Text-to-speech (Edge TTS free, ElevenLabs, OpenAI, MiniMax)
# cronjob - Schedule and manage automated tasks (CLI-only)
# rl - RL training tools (Tinker-Atropos)
#
@@ -789,6 +797,27 @@ display:
#
skin: default
# =============================================================================
# Model Aliases — short names for /model command
# =============================================================================
# Map short aliases to exact (model, provider, base_url) tuples.
# Used by /model tab completion and resolve_alias().
# Aliases are checked BEFORE the models.dev catalog, so they can route
# to endpoints not in the catalog (e.g. Ollama Cloud, local servers).
#
# model_aliases:
# opus:
# model: claude-opus-4-6
# provider: anthropic
# qwen:
# model: "qwen3.5:397b"
# provider: custom
# base_url: "https://ollama.com/v1"
# glm:
# model: glm-4.7
# provider: custom
# base_url: "https://ollama.com/v1"
# =============================================================================
# Privacy
# =============================================================================
+681 -90
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -375,6 +375,7 @@ def create_job(
model: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
script: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
@@ -391,6 +392,9 @@ def create_job(
model: Optional per-job model override
provider: Optional per-job provider override
base_url: Optional per-job base URL override
script: Optional path to a Python script whose stdout is injected into the
prompt each run. The script runs before the agent turn, and its output
is prepended as context. Useful for data collection / change detection.
Returns:
The created job dict
@@ -419,6 +423,8 @@ def create_job(
normalized_model = normalized_model or None
normalized_provider = normalized_provider or None
normalized_base_url = normalized_base_url or None
normalized_script = str(script).strip() if isinstance(script, str) else None
normalized_script = normalized_script or None
label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job"
job = {
@@ -430,6 +436,7 @@ def create_job(
"model": normalized_model,
"provider": normalized_provider,
"base_url": normalized_base_url,
"script": normalized_script,
"schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule),
"repeat": {
+297 -52
View File
@@ -9,11 +9,12 @@ runs at a time if multiple processes overlap.
"""
import asyncio
import concurrent.futures
import json
import logging
import os
import subprocess
import sys
import traceback
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
@@ -24,17 +25,28 @@ except ImportError:
import msvcrt
except ImportError:
msvcrt = None
import time
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config
from typing import Optional
# Add parent directory to path for imports BEFORE repo-level imports.
# Without this, standalone invocations (e.g. after `hermes update` reloads
# the module) fail with ModuleNotFoundError for hermes_time et al.
sys.path.insert(0, str(Path(__file__).parent.parent))
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config
from hermes_time import now as _hermes_now
logger = logging.getLogger(__name__)
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
# Valid delivery platforms — used to validate user-supplied platform names
# in cron delivery targets, preventing env var enumeration via crafted names.
_KNOWN_DELIVERY_PLATFORMS = frozenset({
"telegram", "discord", "slack", "whatsapp", "signal",
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
"wecom", "sms", "email", "webhook",
})
from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run
@@ -72,34 +84,51 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
return None
if deliver == "origin":
if not origin:
return None
return {
"platform": origin["platform"],
"chat_id": str(origin["chat_id"]),
"thread_id": origin.get("thread_id"),
}
if origin:
return {
"platform": origin["platform"],
"chat_id": str(origin["chat_id"]),
"thread_id": origin.get("thread_id"),
}
# Origin missing (e.g. job created via API/script) — try each
# platform's home channel as a fallback instead of silently dropping.
for platform_name in ("matrix", "telegram", "discord", "slack"):
chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "")
if chat_id:
logger.info(
"Job '%s' has deliver=origin but no origin; falling back to %s home channel",
job.get("name", job.get("id", "?")),
platform_name,
)
return {
"platform": platform_name,
"chat_id": chat_id,
"thread_id": None,
}
return None
if ":" in deliver:
platform_name, rest = deliver.split(":", 1)
# Check for thread_id suffix (e.g. "telegram:-1003724596514:17")
if ":" in rest:
chat_id, thread_id = rest.split(":", 1)
platform_key = platform_name.lower()
from tools.send_message_tool import _parse_target_ref
parsed_chat_id, parsed_thread_id, is_explicit = _parse_target_ref(platform_key, rest)
if is_explicit:
chat_id, thread_id = parsed_chat_id, parsed_thread_id
else:
chat_id, thread_id = rest, None
# Resolve human-friendly labels like "Alice (dm)" to real IDs.
# send_message(action="list") shows labels with display suffixes
# that aren't valid platform IDs (e.g. WhatsApp JIDs).
try:
from gateway.channel_directory import resolve_channel_name
target = chat_id
# Strip display suffix like " (dm)" or " (group)"
if target.endswith(")") and " (" in target:
target = target.rsplit(" (", 1)[0].strip()
resolved = resolve_channel_name(platform_name.lower(), target)
resolved = resolve_channel_name(platform_key, chat_id)
if resolved:
chat_id = resolved
parsed_chat_id, parsed_thread_id, resolved_is_explicit = _parse_target_ref(platform_key, resolved)
if resolved_is_explicit:
chat_id, thread_id = parsed_chat_id, parsed_thread_id
else:
chat_id = resolved
except Exception:
pass
@@ -117,6 +146,8 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
"thread_id": origin.get("thread_id"),
}
if platform_name.lower() not in _KNOWN_DELIVERY_PLATFORMS:
return None
chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "")
if not chat_id:
return None
@@ -128,12 +159,14 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]:
}
def _deliver_result(job: dict, content: str) -> None:
def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:
"""
Deliver job output to the configured target (origin chat, specific platform, etc.).
Uses the standalone platform send functions from send_message_tool so delivery
works whether or not the gateway is running.
When ``adapters`` and ``loop`` are provided (gateway is running), tries to
use the live adapter first this supports E2EE rooms (e.g. Matrix) where
the standalone HTTP path cannot encrypt. Falls back to standalone send if
the adapter path fails or is unavailable.
"""
target = _resolve_delivery_target(job)
if not target:
@@ -204,8 +237,38 @@ def _deliver_result(job: dict, content: str) -> None:
else:
delivery_content = content
# Run the async send in a fresh event loop (safe from any thread)
coro = _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id)
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
from gateway.platforms.base import BasePlatformAdapter
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
# 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)
if runtime_adapter is not None and loop is not None and getattr(loop, "is_running", lambda: False)():
send_metadata = {"thread_id": thread_id} if thread_id else None
try:
future = asyncio.run_coroutine_threadsafe(
runtime_adapter.send(chat_id, delivery_content, metadata=send_metadata),
loop,
)
send_result = future.result(timeout=60)
if send_result and not getattr(send_result, "success", True):
err = getattr(send_result, "error", "unknown")
logger.warning(
"Job '%s': live adapter send to %s:%s failed (%s), falling back to standalone",
job["id"], platform_name, chat_id, err,
)
else:
logger.info("Job '%s': delivered to %s:%s via live adapter", job["id"], platform_name, chat_id)
return
except Exception as e:
logger.warning(
"Job '%s': live adapter delivery to %s:%s failed (%s), falling back to standalone",
job["id"], platform_name, chat_id, e,
)
# 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:
result = asyncio.run(coro)
except RuntimeError:
@@ -216,7 +279,7 @@ def _deliver_result(job: dict, content: str) -> None:
coro.close()
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id))
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files))
result = future.result(timeout=30)
except Exception as e:
logger.error("Job '%s': delivery to %s:%s failed: %s", job["id"], platform_name, chat_id, e)
@@ -228,22 +291,132 @@ def _deliver_result(job: dict, content: str) -> None:
logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id)
_SCRIPT_TIMEOUT = 120 # seconds
def _run_job_script(script_path: str) -> tuple[bool, str]:
"""Execute a cron job's data-collection script and capture its output.
Scripts must reside within HERMES_HOME/scripts/. Both relative and
absolute paths are resolved and validated against this directory to
prevent arbitrary script execution via path traversal or absolute
path injection.
Args:
script_path: Path to a Python script. Relative paths are resolved
against HERMES_HOME/scripts/. Absolute and ~-prefixed paths
are also validated to ensure they stay within the scripts dir.
Returns:
(success, output) on failure *output* contains the error message so the
LLM can report the problem to the user.
"""
from hermes_constants import get_hermes_home
scripts_dir = get_hermes_home() / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
scripts_dir_resolved = scripts_dir.resolve()
raw = Path(script_path).expanduser()
if raw.is_absolute():
path = raw.resolve()
else:
path = (scripts_dir / raw).resolve()
# Guard against path traversal, absolute path injection, and symlink
# escape — scripts MUST reside within HERMES_HOME/scripts/.
try:
path.relative_to(scripts_dir_resolved)
except ValueError:
return False, (
f"Blocked: script path resolves outside the scripts directory "
f"({scripts_dir_resolved}): {script_path!r}"
)
if not path.exists():
return False, f"Script not found: {path}"
if not path.is_file():
return False, f"Script path is not a file: {path}"
try:
result = subprocess.run(
[sys.executable, str(path)],
capture_output=True,
text=True,
timeout=_SCRIPT_TIMEOUT,
cwd=str(path.parent),
)
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
if result.returncode != 0:
parts = [f"Script exited with code {result.returncode}"]
if stderr:
parts.append(f"stderr:\n{stderr}")
if stdout:
parts.append(f"stdout:\n{stdout}")
return False, "\n".join(parts)
# Redact any secrets that may appear in script output before
# they are injected into the LLM prompt context.
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
except Exception:
pass
return True, stdout
except subprocess.TimeoutExpired:
return False, f"Script timed out after {_SCRIPT_TIMEOUT}s: {path}"
except Exception as exc:
return False, f"Script execution failed: {exc}"
def _build_job_prompt(job: dict) -> str:
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
prompt = job.get("prompt", "")
skills = job.get("skills")
# Always prepend [SILENT] guidance so the cron agent can suppress
# delivery when it has nothing new or noteworthy to report.
silent_hint = (
"[SYSTEM: If you have a meaningful status report or findings, "
"send them — that is the whole point of this job. Only respond "
"with exactly \"[SILENT]\" (nothing else) when there is genuinely "
"nothing new to report. [SILENT] suppresses delivery to the user. "
# Run data-collection script if configured, inject output as context.
script_path = job.get("script")
if script_path:
success, script_output = _run_job_script(script_path)
if success:
if script_output:
prompt = (
"## Script Output\n"
"The following data was collected by a pre-run script. "
"Use it as context for your analysis.\n\n"
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
else:
prompt = (
"[Script ran successfully but produced no output.]\n\n"
f"{prompt}"
)
else:
prompt = (
"## Script Error\n"
"The data-collection script failed. Report this to the user.\n\n"
f"```\n{script_output}\n```\n\n"
f"{prompt}"
)
# Always prepend cron execution guidance so the agent knows how
# delivery works and can suppress delivery when appropriate.
cron_hint = (
"[SYSTEM: You are running as a scheduled cron job. "
"DELIVERY: Your final response will be automatically delivered "
"to the user — do NOT use send_message or try to deliver "
"the output yourself. Just produce your report/output as your "
"final response and the system handles the rest. "
"SILENT: If there is genuinely nothing new to report, respond "
"with exactly \"[SILENT]\" (nothing else) to suppress delivery. "
"Never combine [SILENT] with content — either report your "
"findings normally, or say [SILENT] and nothing more.]\n\n"
)
prompt = silent_hint + prompt
prompt = cron_hint + prompt
if skills is None:
legacy = job.get("skill")
skills = [legacy] if legacy else []
@@ -316,14 +489,14 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
logger.info("Prompt: %s", prompt[:100])
# Inject origin context so the agent's send_message tool knows the chat
if origin:
os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"]
os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"])
if origin.get("chat_name"):
os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"]
try:
# Inject origin context so the agent's send_message tool knows the chat.
# Must be INSIDE the try block so the finally cleanup always runs.
if origin:
os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"]
os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"])
if origin.get("chat_name"):
os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"]
# Re-read .env and config.yaml fresh every run so provider/key
# changes take effect without a gateway restart.
from dotenv import load_dotenv
@@ -437,13 +610,85 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
provider_sort=pr.get("sort"),
disabled_toolsets=["cronjob", "messaging", "clarify"],
quiet_mode=True,
skip_memory=True, # Cron system prompts would corrupt user representations
platform="cron",
session_id=_cron_session_id,
session_db=_session_db,
)
result = agent.run_conversation(prompt)
# Run the agent with an *inactivity*-based timeout: the job can run
# for hours if it's actively calling tools / receiving stream tokens,
# but a hung API call or stuck tool with no activity for the configured
# duration is caught and killed. Default 600s (10 min inactivity);
# override via HERMES_CRON_TIMEOUT env var. 0 = unlimited.
#
# Uses the agent's built-in activity tracker (updated by
# _touch_activity() on every tool call, API call, and stream delta).
_cron_timeout = float(os.getenv("HERMES_CRON_TIMEOUT", 600))
_cron_inactivity_limit = _cron_timeout if _cron_timeout > 0 else None
_POLL_INTERVAL = 5.0
_cron_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
_cron_future = _cron_pool.submit(agent.run_conversation, prompt)
_inactivity_timeout = False
try:
if _cron_inactivity_limit is None:
# Unlimited — just wait for the result.
result = _cron_future.result()
else:
result = None
while True:
done, _ = concurrent.futures.wait(
{_cron_future}, timeout=_POLL_INTERVAL,
)
if done:
result = _cron_future.result()
break
# Agent still running — check inactivity.
_idle_secs = 0.0
if hasattr(agent, "get_activity_summary"):
try:
_act = agent.get_activity_summary()
_idle_secs = _act.get("seconds_since_activity", 0.0)
except Exception:
pass
if _idle_secs >= _cron_inactivity_limit:
_inactivity_timeout = True
break
except Exception:
_cron_pool.shutdown(wait=False, cancel_futures=True)
raise
finally:
_cron_pool.shutdown(wait=False)
if _inactivity_timeout:
# Build diagnostic summary from the agent's activity tracker.
_activity = {}
if hasattr(agent, "get_activity_summary"):
try:
_activity = agent.get_activity_summary()
except Exception:
pass
_last_desc = _activity.get("last_activity_desc", "unknown")
_secs_ago = _activity.get("seconds_since_activity", 0)
_cur_tool = _activity.get("current_tool")
_iter_n = _activity.get("api_call_count", 0)
_iter_max = _activity.get("max_iterations", 0)
logger.error(
"Job '%s' idle for %.0fs (inactivity limit %.0fs) "
"| last_activity=%s | iteration=%s/%s | tool=%s",
job_name, _secs_ago, _cron_inactivity_limit,
_last_desc, _iter_n, _iter_max,
_cur_tool or "none",
)
if hasattr(agent, "interrupt"):
agent.interrupt("Cron job timed out (inactivity)")
raise TimeoutError(
f"Cron job '{job_name}' idle for "
f"{int(_secs_ago)}s (limit {int(_cron_inactivity_limit)}s) "
f"— last activity: {_last_desc}"
)
final_response = result.get("final_response", "") or ""
# Use a separate variable for log display; keep final_response clean
# for delivery logic (empty response = no delivery).
@@ -469,7 +714,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}"
logger.error("Job '%s' failed: %s", job_name, error_msg)
logger.exception("Job '%s' failed: %s", job_name, error_msg)
output = f"""# Cron Job: {job_name} (FAILED)
@@ -485,8 +730,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
```
{error_msg}
{traceback.format_exc()}
```
"""
return False, output, "", error_msg
@@ -513,7 +756,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
def tick(verbose: bool = True) -> int:
def tick(verbose: bool = True, adapters=None, loop=None) -> int:
"""
Check and run all due jobs.
@@ -522,6 +765,8 @@ def tick(verbose: bool = True) -> int:
Args:
verbose: Whether to print status messages
adapters: Optional dict mapping Platform live adapter (from gateway)
loop: Optional asyncio event loop (from gateway) for live adapter sends
Returns:
Number of jobs executed (0 if another tick is already running)
@@ -572,13 +817,13 @@ def tick(verbose: bool = True) -> int:
# output is already saved above). Failed jobs always deliver.
deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}"
should_deliver = bool(deliver_content)
if should_deliver and success and deliver_content.strip().upper().startswith(SILENT_MARKER):
if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper():
logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER)
should_deliver = False
if should_deliver:
try:
_deliver_result(job, deliver_content)
_deliver_result(job, deliver_content, adapters=adapters, loop=loop)
except Exception as de:
logger.error("Delivery failed for job %s: %s", job["id"], de)
+7 -8
View File
@@ -76,14 +76,13 @@ Open Zed settings (`Cmd+,` on macOS or `Ctrl+,` on Linux) and add to your
```json
{
"acp": {
"agents": [
{
"name": "hermes-agent",
"registry_dir": "/path/to/hermes-agent/acp_registry"
}
]
}
"agent_servers": {
"hermes-agent": {
"type": "custom",
"command": "hermes",
"args": ["acp"],
},
},
}
```
+28 -19
View File
@@ -164,6 +164,11 @@ class HermesAgentLoop:
self.max_tokens = max_tokens
self.extra_body = extra_body
# Per-result and per-turn output persistence (see tools/tool_result_storage.py)
from pathlib import Path
self._tool_result_storage_dir = Path(f"/tmp/hermes_tool_results/{self.task_id}")
self._tool_result_storage_dir.mkdir(parents=True, exist_ok=True)
async def run(self, messages: List[Dict[str, Any]]) -> AgentResult:
"""
Execute the full agent loop using standard OpenAI tool calling.
@@ -193,10 +198,6 @@ class HermesAgentLoop:
import time as _time
prompt_token_ids = None
generation_token_ids = None
generation_log_probs = None
for turn in range(self.max_turns):
turn_start = _time.monotonic()
@@ -250,12 +251,6 @@ class HermesAgentLoop:
)
assistant_msg = response.choices[0].message
if hasattr(assistant_msg, "prompt_token_ids"):
prompt_token_ids = assistant_msg.prompt_token_ids
if hasattr(assistant_msg, "generation_token_ids"):
generation_token_ids = assistant_msg.generation_token_ids
if hasattr(assistant_msg, "generation_log_probs"):
generation_log_probs = assistant_msg.generation_log_probs
# Extract reasoning content from the response (all provider formats)
reasoning = _extract_reasoning_from_message(assistant_msg)
@@ -318,10 +313,7 @@ class HermesAgentLoop:
"content": assistant_msg.content or "",
"tool_calls": [_tc_to_dict(tc) for tc in assistant_msg.tool_calls],
}
if prompt_token_ids is not None:
msg_dict["prompt_token_ids"] = prompt_token_ids
msg_dict["generation_token_ids"] = generation_token_ids
msg_dict["generation_log_probs"] = generation_log_probs
# Preserve reasoning_content for multi-turn chat template handling
# (e.g., Kimi-K2's template renders <think> blocks differently
# for history vs. the latest turn based on this field)
@@ -459,8 +451,18 @@ class HermesAgentLoop:
except (json.JSONDecodeError, TypeError):
pass
# Add tool response to conversation
# Persist oversized results to disk
tc_id = tc.get("id", "") if isinstance(tc, dict) else tc.id
try:
from tools.tool_result_storage import maybe_persist_tool_result
tool_result = maybe_persist_tool_result(
content=tool_result,
tool_name=tool_name,
tool_use_id=tc_id,
storage_dir=self._tool_result_storage_dir,
)
except Exception:
pass # Persistence is best-effort in eval path
messages.append(
{
"role": "tool",
@@ -469,6 +471,17 @@ class HermesAgentLoop:
}
)
# Per-turn aggregate budget enforcement
try:
from tools.tool_result_storage import enforce_turn_budget
num_tcs = len(assistant_msg.tool_calls)
if num_tcs > 0:
turn_msgs = [m for m in messages[-num_tcs * 2:]
if m.get("role") == "tool"]
enforce_turn_budget(turn_msgs, self._tool_result_storage_dir)
except Exception:
pass # Best-effort in eval path
turn_elapsed = _time.monotonic() - turn_start
logger.info(
"[%s] turn %d: api=%.1fs, %d tools, turn_total=%.1fs",
@@ -484,10 +497,6 @@ class HermesAgentLoop:
}
if reasoning:
msg_dict["reasoning_content"] = reasoning
if prompt_token_ids is not None:
msg_dict["prompt_token_ids"] = prompt_token_ids
msg_dict["generation_token_ids"] = generation_token_ids
msg_dict["generation_log_probs"] = generation_log_probs
messages.append(msg_dict)
turn_elapsed = _time.monotonic() - turn_start
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env python3
"""
Quick compatibility check: connect to a local OpenAI-compatible endpoint
and run a single agent turn via HermesAgentLoop with all standard tools.
Usage:
python environments/check_gym_compat.py # auto-detect model
python environments/check_gym_compat.py --model my-model # explicit model
python environments/check_gym_compat.py --base-url http://... --model ...
"""
import asyncio
import argparse
import json
import logging
import sys
from pathlib import Path
# Ensure repo root is on sys.path when run as a standalone script
_repo_root = str(Path(__file__).resolve().parent.parent)
if _repo_root not in sys.path:
sys.path.insert(0, _repo_root)
import requests
from openai import AsyncOpenAI
from environments.agent_loop import HermesAgentLoop, AgentResult
from model_tools import get_tool_definitions
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Thin server wrapper — gives HermesAgentLoop the chat_completion() it wants
# ---------------------------------------------------------------------------
class OpenAIServer:
"""Minimal async server wrapping an OpenAI-compatible endpoint."""
def __init__(self, base_url: str, model: str, api_key: str = "dummy"):
self.model = model
self.client = AsyncOpenAI(base_url=base_url, api_key=api_key)
async def chat_completion(self, **kwargs):
kwargs.setdefault("model", self.model)
return await self.client.chat.completions.create(**kwargs)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def detect_model(base_url: str) -> str:
try:
resp = requests.get(f"{base_url}/models", timeout=10)
resp.raise_for_status()
models = resp.json().get("data", [])
if not models:
print("WARNING: /v1/models returned no models")
return "default"
model_id = models[0]["id"]
print(f"Auto-detected model: {model_id}")
return model_id
except Exception as e:
print(f"Could not auto-detect model ({e}), falling back to 'default'")
return "default"
async def run_check(base_url: str, model: str, message: str) -> AgentResult:
server = OpenAIServer(base_url=base_url, model=model)
# Get all default hermes tools
tool_schemas = get_tool_definitions(quiet_mode=False)
valid_names = {t["function"]["name"] for t in tool_schemas}
agent = HermesAgentLoop(
server=server,
tool_schemas=tool_schemas,
valid_tool_names=valid_names,
max_turns=5,
)
messages = [
{"role": "system", "content": "You are a helpful assistant with access to tools."},
{"role": "user", "content": message},
]
return await agent.run(messages)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Check gym endpoint compatibility")
parser.add_argument("--base-url", default="http://127.0.0.1:11746/v1")
parser.add_argument("--model", default=None)
parser.add_argument("--message", default="Hello! What's the current directory you're in?")
args = parser.parse_args()
model = args.model or detect_model(args.base_url)
print(f"\n{'='*60}")
print(f"Endpoint: {args.base_url}")
print(f"Model: {model}")
print(f"Message: {args.message}")
print(f"{'='*60}\n")
try:
result = asyncio.run(run_check(args.base_url, model, args.message))
print(f"\n{'='*60}")
print(f"Turns used: {result.turns_used}")
print(f"Finished naturally: {result.finished_naturally}")
print(f"Tool errors: {len(result.tool_errors)}")
print(f"{'='*60}")
# Print the final assistant response
for msg in reversed(result.messages):
# if msg.get("role") == "assistant" and msg.get("content"):
# print("\nRESPONSE:")
# print(msg["content"])
# break
print(msg)
if result.tool_errors:
print("\nTOOL ERRORS:")
for err in result.tool_errors:
print(f" turn {err.turn}: {err.tool_name}{err.error}")
status = "✅ passed" if result.finished_naturally else "⚠️ hit max turns"
print(f"\nGym compatibility check {status}")
except Exception as e:
print(f"\n❌ Gym compatibility check failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
+4 -11
View File
@@ -11,11 +11,11 @@ Solution:
_AsyncWorker thread internally, making it safe for both CLI and Atropos use.
No monkey-patching is required.
This module is kept for backward compatibility apply_patches() is now a no-op.
This module is kept for backward compatibility. apply_patches() is a no-op.
Usage:
Call apply_patches() once at import time (done automatically by hermes_base_env.py).
This is idempotent calling it multiple times is safe.
This is idempotent and safe to call multiple times.
"""
import logging
@@ -26,17 +26,10 @@ _patches_applied = False
def apply_patches():
"""Apply all monkey patches needed for Atropos compatibility.
Now a no-op Modal async safety is built directly into ModalEnvironment.
Safe to call multiple times.
"""
"""Apply all monkey patches needed for Atropos compatibility."""
global _patches_applied
if _patches_applied:
return
# Modal async-safety is now built into tools/environments/modal.py
# via the _AsyncWorker class. No monkey-patching needed.
logger.debug("apply_patches() called — no patches needed (async safety is built-in)")
logger.debug("apply_patches() called; no patches needed (async safety is built-in)")
_patches_applied = True
+27 -13
View File
@@ -12,12 +12,27 @@ from datetime import datetime
from typing import Any, Dict, List, Optional
from hermes_cli.config import get_hermes_home
from utils import atomic_json_write
logger = logging.getLogger(__name__)
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
def _normalize_channel_query(value: str) -> str:
return value.lstrip("#").strip().lower()
def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str:
"""Return the human-facing target label shown to users for a channel entry."""
name = channel["name"]
if platform_name == "discord" and channel.get("guild"):
return f"#{name}"
if platform_name != "discord" and channel.get("type"):
return f"{name} ({channel['type']})"
return name
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
chat_id = origin.get("chat_id")
if not chat_id:
@@ -72,9 +87,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
}
try:
DIRECTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DIRECTORY_PATH, "w", encoding="utf-8") as f:
json.dump(directory, f, indent=2, ensure_ascii=False)
atomic_json_write(DIRECTORY_PATH, directory)
except Exception as e:
logger.warning("Channel directory: failed to write: %s", e)
@@ -188,23 +201,25 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
if not channels:
return None
query = name.lstrip("#").lower()
query = _normalize_channel_query(name)
# 1. Exact name match
# 1. Exact name match, including the display labels shown by send_message(action="list")
for ch in channels:
if ch["name"].lower() == query:
if _normalize_channel_query(ch["name"]) == query:
return ch["id"]
if _normalize_channel_query(_channel_target_name(platform_name, ch)) == query:
return ch["id"]
# 2. Guild-qualified match for Discord ("GuildName/channel")
if "/" in query:
guild_part, ch_part = query.rsplit("/", 1)
for ch in channels:
guild = ch.get("guild", "").lower()
if guild == guild_part and ch["name"].lower() == ch_part:
guild = ch.get("guild", "").strip().lower()
if guild == guild_part and _normalize_channel_query(ch["name"]) == ch_part:
return ch["id"]
# 3. Partial prefix match (only if unambiguous)
matches = [ch for ch in channels if ch["name"].lower().startswith(query)]
matches = [ch for ch in channels if _normalize_channel_query(ch["name"]).startswith(query)]
if len(matches) == 1:
return matches[0]["id"]
@@ -239,17 +254,16 @@ def format_directory_for_display() -> str:
for guild_name, guild_channels in sorted(guilds.items()):
lines.append(f"Discord ({guild_name}):")
for ch in sorted(guild_channels, key=lambda c: c["name"]):
lines.append(f" discord:#{ch['name']}")
lines.append(f" discord:{_channel_target_name(plat_name, ch)}")
if dms:
lines.append("Discord (DMs):")
for ch in dms:
lines.append(f" discord:{ch['name']}")
lines.append(f" discord:{_channel_target_name(plat_name, ch)}")
lines.append("")
else:
lines.append(f"{plat_name.title()}:")
for ch in channels:
type_label = f" ({ch['type']})" if ch.get("type") else ""
lines.append(f" {plat_name}:{ch['name']}{type_label}")
lines.append(f" {plat_name}:{_channel_target_name(plat_name, ch)}")
lines.append("")
lines.append('Use these as the "target" parameter when sending.')
+38 -7
View File
@@ -17,6 +17,7 @@ from typing import Dict, List, Optional, Any
from enum import Enum
from hermes_cli.config import get_hermes_home
from utils import is_truthy_value
logger = logging.getLogger(__name__)
@@ -25,10 +26,6 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
"""Coerce bool-ish config values, preserving a caller-provided default."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "yes", "on"):
@@ -36,7 +33,7 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
if lowered in ("false", "0", "no", "off"):
return False
return default
return default
return is_truthy_value(value, default=default)
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
@@ -249,6 +246,7 @@ class GatewayConfig:
# Session isolation in shared chats
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants
# Unauthorized DM policy
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
@@ -336,6 +334,7 @@ class GatewayConfig:
"always_log_local": self.always_log_local,
"stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
"thread_sessions_per_user": self.thread_sessions_per_user,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
}
@@ -379,6 +378,7 @@ class GatewayConfig:
stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None
group_sessions_per_user = data.get("group_sessions_per_user")
thread_sessions_per_user = data.get("thread_sessions_per_user")
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
data.get("unauthorized_dm_behavior"),
"pair",
@@ -395,6 +395,7 @@ class GatewayConfig:
always_log_local=data.get("always_log_local", True),
stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
)
@@ -470,6 +471,9 @@ def load_gateway_config() -> GatewayConfig:
if "group_sessions_per_user" in yaml_cfg:
gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"]
if "thread_sessions_per_user" in yaml_cfg:
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
streaming_cfg = yaml_cfg.get("streaming")
if isinstance(streaming_cfg, dict):
gw_data["streaming"] = streaming_cfg
@@ -566,6 +570,32 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
if isinstance(whatsapp_cfg, dict):
if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"):
os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower()
if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"):
os.environ["WHATSAPP_MENTION_PATTERNS"] = json.dumps(whatsapp_cfg["mention_patterns"])
frc = whatsapp_cfg.get("free_response_chats")
if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
# Matrix settings → env vars (env vars take precedence)
matrix_cfg = yaml_cfg.get("matrix", {})
if isinstance(matrix_cfg, dict):
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
frc = matrix_cfg.get("free_response_rooms")
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
except Exception as e:
logger.warning(
"Failed to process config.yaml — falling back to .env / gateway.json values. "
@@ -749,6 +779,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.MATRIX].extra["password"] = matrix_password
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
if matrix_device_id:
config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id
matrix_home = os.getenv("MATRIX_HOME_ROOM")
if matrix_home and Platform.MATRIX in config.platforms:
config.platforms[Platform.MATRIX].home_channel = HomeChannel(
@@ -908,5 +941,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.default_reset_policy.at_hour = int(reset_hour)
except ValueError:
pass
+79 -54
View File
@@ -21,6 +21,8 @@ Storage: ~/.hermes/pairing/
import json
import os
import secrets
import tempfile
import threading
import time
from pathlib import Path
from typing import Optional
@@ -45,13 +47,29 @@ PAIRING_DIR = get_hermes_dir("platforms/pairing", "pairing")
def _secure_write(path: Path, data: str) -> None:
"""Write data to file with restrictive permissions (owner read/write only)."""
"""Write data to file with restrictive permissions (owner read/write only).
Uses a temp-file + atomic rename so readers always see either the old
complete file or the new one never a partial write.
"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data, encoding="utf-8")
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
os.chmod(path, 0o600)
except OSError:
pass # Windows doesn't support chmod the same way
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, str(path))
try:
os.chmod(path, 0o600)
except OSError:
pass # Windows doesn't support chmod the same way
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
class PairingStore:
@@ -66,6 +84,9 @@ class PairingStore:
def __init__(self):
PAIRING_DIR.mkdir(parents=True, exist_ok=True)
# Protects all read-modify-write cycles. The gateway runs multiple
# platform adapters concurrently in threads sharing one PairingStore.
self._lock = threading.RLock()
def _pending_path(self, platform: str) -> Path:
return PAIRING_DIR / f"{platform}-pending.json"
@@ -105,7 +126,7 @@ class PairingStore:
return results
def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None:
"""Add a user to the approved list."""
"""Add a user to the approved list. Must be called under self._lock."""
approved = self._load_json(self._approved_path(platform))
approved[user_id] = {
"user_name": user_name,
@@ -116,11 +137,12 @@ class PairingStore:
def revoke(self, platform: str, user_id: str) -> bool:
"""Remove a user from the approved list. Returns True if found."""
path = self._approved_path(platform)
approved = self._load_json(path)
if user_id in approved:
del approved[user_id]
self._save_json(path, approved)
return True
with self._lock:
approved = self._load_json(path)
if user_id in approved:
del approved[user_id]
self._save_json(path, approved)
return True
return False
# ----- Pending codes -----
@@ -136,36 +158,37 @@ class PairingStore:
- Max pending codes reached for this platform
- User/platform is in lockout due to failed attempts
"""
self._cleanup_expired(platform)
with self._lock:
self._cleanup_expired(platform)
# Check lockout
if self._is_locked_out(platform):
return None
# Check lockout
if self._is_locked_out(platform):
return None
# Check rate limit for this specific user
if self._is_rate_limited(platform, user_id):
return None
# Check rate limit for this specific user
if self._is_rate_limited(platform, user_id):
return None
# Check max pending
pending = self._load_json(self._pending_path(platform))
if len(pending) >= MAX_PENDING_PER_PLATFORM:
return None
# Check max pending
pending = self._load_json(self._pending_path(platform))
if len(pending) >= MAX_PENDING_PER_PLATFORM:
return None
# Generate cryptographically random code
code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
# Generate cryptographically random code
code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
# Store pending request
pending[code] = {
"user_id": user_id,
"user_name": user_name,
"created_at": time.time(),
}
self._save_json(self._pending_path(platform), pending)
# Store pending request
pending[code] = {
"user_id": user_id,
"user_name": user_name,
"created_at": time.time(),
}
self._save_json(self._pending_path(platform), pending)
# Record rate limit
self._record_rate_limit(platform, user_id)
# Record rate limit
self._record_rate_limit(platform, user_id)
return code
return code
def approve_code(self, platform: str, code: str) -> Optional[dict]:
"""
@@ -173,24 +196,25 @@ class PairingStore:
Returns {user_id, user_name} on success, None if code is invalid/expired.
"""
self._cleanup_expired(platform)
code = code.upper().strip()
with self._lock:
self._cleanup_expired(platform)
code = code.upper().strip()
pending = self._load_json(self._pending_path(platform))
if code not in pending:
self._record_failed_attempt(platform)
return None
pending = self._load_json(self._pending_path(platform))
if code not in pending:
self._record_failed_attempt(platform)
return None
entry = pending.pop(code)
self._save_json(self._pending_path(platform), pending)
entry = pending.pop(code)
self._save_json(self._pending_path(platform), pending)
# Add to approved list
self._approve_user(platform, entry["user_id"], entry.get("user_name", ""))
# Add to approved list
self._approve_user(platform, entry["user_id"], entry.get("user_name", ""))
return {
"user_id": entry["user_id"],
"user_name": entry.get("user_name", ""),
}
return {
"user_id": entry["user_id"],
"user_name": entry.get("user_name", ""),
}
def list_pending(self, platform: str = None) -> list:
"""List pending pairing requests, optionally filtered by platform."""
@@ -212,12 +236,13 @@ class PairingStore:
def clear_pending(self, platform: str = None) -> int:
"""Clear all pending requests. Returns count removed."""
count = 0
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
pending = self._load_json(self._pending_path(p))
count += len(pending)
self._save_json(self._pending_path(p), {})
with self._lock:
count = 0
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
pending = self._load_json(self._pending_path(p))
count += len(pending)
self._save_json(self._pending_path(p), {})
return count
# ----- Rate limiting and lockout -----
+287 -4
View File
@@ -7,6 +7,8 @@ Exposes an HTTP server with endpoints:
- GET /v1/responses/{response_id} Retrieve a stored response
- DELETE /v1/responses/{response_id} Delete a stored response
- GET /v1/models lists hermes-agent as an available model
- POST /v1/runs start a run, returns run_id immediately (202)
- GET /v1/runs/{run_id}/events SSE stream of structured lifecycle events
- GET /health health check
Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
@@ -300,6 +302,10 @@ class APIServerAdapter(BasePlatformAdapter):
self._runner: Optional["web.AppRunner"] = None
self._site: Optional["web.TCPSite"] = None
self._response_store = ResponseStore()
# Active run streams: run_id -> asyncio.Queue of SSE event dicts
self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {}
# Creation timestamps for orphaned-run TTL sweep
self._run_streams_created: Dict[str, float] = {}
self._session_db: Optional[Any] = None # Lazy-init SessionDB for session continuity
@staticmethod
@@ -372,6 +378,24 @@ class APIServerAdapter(BasePlatformAdapter):
status=401,
)
# ------------------------------------------------------------------
# Session DB helper
# ------------------------------------------------------------------
def _ensure_session_db(self):
"""Lazily initialise and return the shared SessionDB instance.
Sessions are persisted to ``state.db`` so that ``hermes sessions list``
shows API-server conversations alongside CLI and gateway ones.
"""
if self._session_db is None:
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception as e:
logger.debug("SessionDB unavailable for API server: %s", e)
return self._session_db
# ------------------------------------------------------------------
# Agent creation helper
# ------------------------------------------------------------------
@@ -403,6 +427,11 @@ class APIServerAdapter(BasePlatformAdapter):
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
# Load fallback provider chain so the API server platform has the
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
from gateway.run import GatewayRunner
fallback_model = GatewayRunner._load_fallback_model()
agent = AIAgent(
model=model,
**runtime_kwargs,
@@ -415,6 +444,8 @@ class APIServerAdapter(BasePlatformAdapter):
platform="api_server",
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
session_db=self._ensure_session_db(),
fallback_model=fallback_model,
)
return agent
@@ -503,10 +534,9 @@ class APIServerAdapter(BasePlatformAdapter):
if provided_session_id:
session_id = provided_session_id
try:
if self._session_db is None:
from hermes_state import SessionDB
self._session_db = SessionDB()
history = self._session_db.get_messages_as_conversation(session_id)
db = self._ensure_session_db()
if db is not None:
history = db.get_messages_as_conversation(session_id)
except Exception as e:
logger.warning("Failed to load session history for %s: %s", session_id, e)
history = []
@@ -944,6 +974,18 @@ class APIServerAdapter(BasePlatformAdapter):
resume_job as _cron_resume,
trigger_job as _cron_trigger,
)
# Wrap as staticmethod to prevent descriptor binding — these are plain
# module functions, not instance methods. Without this, self._cron_*()
# injects ``self`` as the first positional argument and every call
# raises TypeError.
_cron_list = staticmethod(_cron_list)
_cron_get = staticmethod(_cron_get)
_cron_create = staticmethod(_cron_create)
_cron_update = staticmethod(_cron_update)
_cron_remove = staticmethod(_cron_remove)
_cron_pause = staticmethod(_cron_pause)
_cron_resume = staticmethod(_cron_resume)
_cron_trigger = staticmethod(_cron_trigger)
_CRON_AVAILABLE = True
except ImportError:
pass
@@ -1263,6 +1305,236 @@ class APIServerAdapter(BasePlatformAdapter):
return await loop.run_in_executor(None, _run)
# ------------------------------------------------------------------
# /v1/runs — structured event streaming
# ------------------------------------------------------------------
_MAX_CONCURRENT_RUNS = 10 # Prevent unbounded resource allocation
_RUN_STREAM_TTL = 300 # seconds before orphaned runs are swept
def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"):
"""Return a tool_progress_callback that pushes structured events to the run's SSE queue."""
def _push(event: Dict[str, Any]) -> None:
q = self._run_streams.get(run_id)
if q is None:
return
try:
loop.call_soon_threadsafe(q.put_nowait, event)
except Exception:
pass
def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
ts = time.time()
if event_type == "tool.started":
_push({
"event": "tool.started",
"run_id": run_id,
"timestamp": ts,
"tool": tool_name,
"preview": preview,
})
elif event_type == "tool.completed":
_push({
"event": "tool.completed",
"run_id": run_id,
"timestamp": ts,
"tool": tool_name,
"duration": round(kwargs.get("duration", 0), 3),
"error": kwargs.get("is_error", False),
})
elif event_type == "reasoning.available":
_push({
"event": "reasoning.available",
"run_id": run_id,
"timestamp": ts,
"text": preview or "",
})
# _thinking and subagent_progress are intentionally not forwarded
return _callback
async def _handle_runs(self, request: "web.Request") -> "web.Response":
"""POST /v1/runs — start an agent run, return run_id immediately."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
# Enforce concurrency limit
if len(self._run_streams) >= self._MAX_CONCURRENT_RUNS:
return web.json_response(
_openai_error(f"Too many concurrent runs (max {self._MAX_CONCURRENT_RUNS})", code="rate_limit_exceeded"),
status=429,
)
try:
body = await request.json()
except Exception:
return web.json_response(_openai_error("Invalid JSON"), status=400)
raw_input = body.get("input")
if not raw_input:
return web.json_response(_openai_error("Missing 'input' field"), status=400)
user_message = raw_input if isinstance(raw_input, str) else (raw_input[-1].get("content", "") if isinstance(raw_input, list) else "")
if not user_message:
return web.json_response(_openai_error("No user message found in input"), status=400)
run_id = f"run_{uuid.uuid4().hex}"
loop = asyncio.get_running_loop()
q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue()
self._run_streams[run_id] = q
self._run_streams_created[run_id] = time.time()
event_cb = self._make_run_event_callback(run_id, loop)
# Also wire stream_delta_callback so message.delta events flow through
def _text_cb(delta: Optional[str]) -> None:
if delta is None:
return
try:
loop.call_soon_threadsafe(q.put_nowait, {
"event": "message.delta",
"run_id": run_id,
"timestamp": time.time(),
"delta": delta,
})
except Exception:
pass
instructions = body.get("instructions")
previous_response_id = body.get("previous_response_id")
conversation_history: List[Dict[str, str]] = []
if previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored:
conversation_history = list(stored.get("conversation_history", []))
if instructions is None:
instructions = stored.get("instructions")
session_id = body.get("session_id") or run_id
ephemeral_system_prompt = instructions
async def _run_and_close():
try:
agent = self._create_agent(
ephemeral_system_prompt=ephemeral_system_prompt,
session_id=session_id,
stream_delta_callback=_text_cb,
tool_progress_callback=event_cb,
)
def _run_sync():
r = agent.run_conversation(
user_message=user_message,
conversation_history=conversation_history,
)
u = {
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
return r, u
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
q.put_nowait({
"event": "run.completed",
"run_id": run_id,
"timestamp": time.time(),
"output": final_response,
"usage": usage,
})
except Exception as exc:
logger.exception("[api_server] run %s failed", run_id)
try:
q.put_nowait({
"event": "run.failed",
"run_id": run_id,
"timestamp": time.time(),
"error": str(exc),
})
except Exception:
pass
finally:
# Sentinel: signal SSE stream to close
try:
q.put_nowait(None)
except Exception:
pass
task = asyncio.create_task(_run_and_close())
try:
self._background_tasks.add(task)
except TypeError:
pass
if hasattr(task, "add_done_callback"):
task.add_done_callback(self._background_tasks.discard)
return web.json_response({"run_id": run_id, "status": "started"}, status=202)
async def _handle_run_events(self, request: "web.Request") -> "web.StreamResponse":
"""GET /v1/runs/{run_id}/events — SSE stream of structured agent lifecycle events."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
run_id = request.match_info["run_id"]
# Allow subscribing slightly before the run is registered (race condition window)
for _ in range(20):
if run_id in self._run_streams:
break
await asyncio.sleep(0.05)
else:
return web.json_response(_openai_error(f"Run not found: {run_id}", code="run_not_found"), status=404)
q = self._run_streams[run_id]
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
await response.prepare(request)
try:
while True:
try:
event = await asyncio.wait_for(q.get(), timeout=30.0)
except asyncio.TimeoutError:
await response.write(b": keepalive\n\n")
continue
if event is None:
# Run finished — send final SSE comment and close
await response.write(b": stream closed\n\n")
break
payload = f"data: {json.dumps(event)}\n\n"
await response.write(payload.encode())
except Exception as exc:
logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc)
finally:
self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
return response
async def _sweep_orphaned_runs(self) -> None:
"""Periodically clean up run streams that were never consumed."""
while True:
await asyncio.sleep(60)
now = time.time()
stale = [
run_id
for run_id, created_at in list(self._run_streams_created.items())
if now - created_at > self._RUN_STREAM_TTL
]
for run_id in stale:
logger.debug("[api_server] sweeping orphaned run %s", run_id)
self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
# ------------------------------------------------------------------
# BasePlatformAdapter interface
# ------------------------------------------------------------------
@@ -1293,6 +1565,17 @@ class APIServerAdapter(BasePlatformAdapter):
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
# Structured event streaming
self._app.router.add_post("/v1/runs", self._handle_runs)
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
# Start background sweep to clean up orphaned (unconsumed) run streams
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
try:
self._background_tasks.add(sweep_task)
except TypeError:
pass
if hasattr(sweep_task, "add_done_callback"):
sweep_task.add_done_callback(self._background_tasks.discard)
# Port conflict detection — fail fast if port is already in use
import socket as _socket
+152 -15
View File
@@ -12,6 +12,7 @@ import random
import re
import uuid
from abc import ABC, abstractmethod
from urllib.parse import urlsplit
logger = logging.getLogger(__name__)
from dataclasses import dataclass, field
@@ -36,6 +37,43 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
)
def _safe_url_for_log(url: str, max_len: int = 80) -> str:
"""Return a URL string safe for logs (no query/fragment/userinfo)."""
if max_len <= 0:
return ""
if url is None:
return ""
raw = str(url)
if not raw:
return ""
try:
parsed = urlsplit(raw)
except Exception:
return raw[:max_len]
if parsed.scheme and parsed.netloc:
# Strip potential embedded credentials (user:pass@host).
netloc = parsed.netloc.rsplit("@", 1)[-1]
base = f"{parsed.scheme}://{netloc}"
path = parsed.path or ""
if path and path != "/":
basename = path.rsplit("/", 1)[-1]
safe = f"{base}/.../{basename}" if basename else f"{base}/..."
else:
safe = base
else:
safe = raw
if len(safe) <= max_len:
return safe
if max_len <= 3:
return "." * max_len
return f"{safe[:max_len - 3]}..."
# ---------------------------------------------------------------------------
# Image cache utilities
#
@@ -112,8 +150,14 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
raise
if attempt < retries:
wait = 1.5 * (attempt + 1)
_log.debug("Media cache retry %d/%d for %s (%.1fs): %s",
attempt + 1, retries, url[:80], wait, exc)
_log.debug(
"Media cache retry %d/%d for %s (%.1fs): %s",
attempt + 1,
retries,
_safe_url_for_log(url),
wait,
exc,
)
await asyncio.sleep(wait)
continue
raise
@@ -214,8 +258,14 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
raise
if attempt < retries:
wait = 1.5 * (attempt + 1)
_log.debug("Audio cache retry %d/%d for %s (%.1fs): %s",
attempt + 1, retries, url[:80], wait, exc)
_log.debug(
"Audio cache retry %d/%d for %s (%.1fs): %s",
attempt + 1,
retries,
_safe_url_for_log(url),
wait,
exc,
)
await asyncio.sleep(wait)
continue
raise
@@ -235,6 +285,7 @@ SUPPORTED_DOCUMENT_TYPES = {
".pdf": "application/pdf",
".md": "text/markdown",
".txt": "text/plain",
".zip": "application/zip",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
@@ -376,23 +427,26 @@ class SendResult:
message_id: Optional[str] = None
error: Optional[str] = None
raw_response: Any = None
retryable: bool = False # True for transient errors (network, timeout) — base will retry automatically
retryable: bool = False # True for transient connection errors — base will retry automatically
# Error substrings that indicate a transient network failure worth retrying
# Error substrings that indicate a transient *connection* failure worth retrying.
# "timeout" / "timed out" / "readtimeout" / "writetimeout" are intentionally
# excluded: a read/write timeout on a non-idempotent call (e.g. send_message)
# means the request may have reached the server — retrying risks duplicate
# delivery. "connecttimeout" is safe because the connection was never
# established. Platforms that know a timeout is safe to retry should set
# SendResult.retryable = True explicitly.
_RETRYABLE_ERROR_PATTERNS = (
"connecterror",
"connectionerror",
"connectionreset",
"connectionrefused",
"timeout",
"timed out",
"connecttimeout",
"network",
"broken pipe",
"remotedisconnected",
"eoferror",
"readtimeout",
"writetimeout",
)
@@ -926,6 +980,18 @@ class BasePlatformAdapter(ABC):
lowered = error.lower()
return any(pat in lowered for pat in _RETRYABLE_ERROR_PATTERNS)
@staticmethod
def _is_timeout_error(error: Optional[str]) -> bool:
"""Return True if the error string indicates a read/write timeout.
Timeout errors are NOT retryable and should NOT trigger plain-text
fallback the request may have already been delivered.
"""
if not error:
return False
lowered = error.lower()
return "timed out" in lowered or "readtimeout" in lowered or "writetimeout" in lowered
async def _send_with_retry(
self,
chat_id: str,
@@ -957,6 +1023,11 @@ class BasePlatformAdapter(ABC):
error_str = result.error or ""
is_network = result.retryable or self._is_retryable_error(error_str)
# Timeout errors are not safe to retry (message may have been
# delivered) and not formatting errors — return the failure as-is.
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
# Retry with exponential backoff for transient errors
for attempt in range(1, max_retries + 1):
@@ -1017,10 +1088,59 @@ class BasePlatformAdapter(ABC):
session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
# Check if there's already an active handler for this session
if session_key in self._active_sessions:
# /approve and /deny must bypass the active-session guard.
# The agent thread is blocked on threading.Event.wait() inside
# tools/approval.py — queuing these commands creates a deadlock:
# the agent waits for approval, approval waits for agent to finish.
# Dispatch directly to the message handler without touching session
# lifecycle (no competing background task, no session guard removal).
cmd = event.get_command()
if cmd in ("approve", "deny"):
logger.debug(
"[%s] Approval command '/%s' bypassing active-session guard for %s",
self.name, cmd, session_key,
)
try:
_thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
response = await self._message_handler(event)
if response:
await self._send_with_retry(
chat_id=event.source.chat_id,
content=response,
reply_to=event.message_id,
metadata=_thread_meta,
)
except Exception as e:
logger.error("[%s] Approval dispatch failed: %s", self.name, e, exc_info=True)
return
# /status must also bypass the active-session guard so it always
# returns a system-generated response instead of being queued as
# user text and passed to the agent (#5046).
if cmd == "status":
logger.debug(
"[%s] Status command bypassing active-session guard for %s",
self.name, session_key,
)
try:
_thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
response = await self._message_handler(event)
if response:
await self._send_with_retry(
chat_id=event.source.chat_id,
content=response,
reply_to=event.message_id,
metadata=_thread_meta,
)
except Exception as e:
logger.error("[%s] Status dispatch failed: %s", self.name, e, exc_info=True)
return
# Special case: photo bursts/albums frequently arrive as multiple near-
# simultaneous messages. Queue them without interrupting the active run,
# then process them immediately after the current task finishes.
@@ -1046,6 +1166,13 @@ class BasePlatformAdapter(ABC):
self._active_sessions[session_key].set()
return # Don't process now - will be handled after current task finishes
# Mark session as active BEFORE spawning background task to close
# the race window where a second message arriving before the task
# starts would also pass the _active_sessions check and spawn a
# duplicate task. (grammY sequentialize / aiogram EventIsolation
# pattern — set the guard synchronously, not inside the task.)
self._active_sessions[session_key] = asyncio.Event()
# Spawn background task to process this message
task = asyncio.create_task(self._process_message_background(event, session_key))
try:
@@ -1092,8 +1219,10 @@ class BasePlatformAdapter(ABC):
if getattr(result, "success", False):
delivery_succeeded = True
# Create interrupt event for this session
interrupt_event = asyncio.Event()
# Reuse the interrupt event set by handle_message() (which marks
# the session active before spawning this task to prevent races).
# Fall back to a new Event only if the entry was removed externally.
interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
self._active_sessions[session_key] = interrupt_event
# Start continuous typing indicator (refreshes every 2 seconds)
@@ -1106,9 +1235,12 @@ class BasePlatformAdapter(ABC):
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
# Send response if any
# Send response if any. A None/empty response is normal when
# streaming already delivered the text (already_sent=True) or
# when the message was queued behind an active agent. Log at
# DEBUG to avoid noisy warnings for expected behavior.
if not response:
logger.warning("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
if response:
# Extract MEDIA:<path> tags (from TTS tool) before other processing
media_files, response = self.extract_media(response)
@@ -1184,7 +1316,12 @@ class BasePlatformAdapter(ABC):
if human_delay > 0:
await asyncio.sleep(human_delay)
try:
logger.info("[%s] Sending image: %s (alt=%s)", self.name, image_url[:80], alt_text[:30] if alt_text else "")
logger.info(
"[%s] Sending image: %s (alt=%s)",
self.name,
_safe_url_for_log(image_url),
alt_text[:30] if alt_text else "",
)
# Route animated GIFs through send_animation for proper playback
if self._is_animation_url(image_url):
img_result = await self.send_animation(
+270 -38
View File
@@ -449,6 +449,11 @@ class DiscordAdapter(BasePlatformAdapter):
self._bot_task: Optional[asyncio.Task] = None
# Cap to prevent unbounded growth (Discord threads get archived).
self._MAX_TRACKED_THREADS = 500
# Dedup cache: message_id → timestamp. Prevents duplicate bot
# responses when Discord RESUME replays events after reconnects.
self._seen_messages: Dict[str, float] = {}
self._SEEN_TTL = 300 # 5 minutes
self._SEEN_MAX = 2000 # prune threshold
async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
@@ -497,19 +502,6 @@ class DiscordAdapter(BasePlatformAdapter):
self._set_fatal_error('discord_token_lock', message, retryable=False)
return False
# Set up intents -- members intent needed for username-to-ID resolution
intents = Intents.default()
intents.message_content = True
intents.dm_messages = True
intents.guild_messages = True
intents.members = True
intents.voice_states = True
# Create bot
self._client = commands.Bot(
command_prefix="!", # Not really used, we handle raw messages
intents=intents,
)
# Parse allowed user entries (may contain usernames or IDs)
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
@@ -519,6 +511,25 @@ class DiscordAdapter(BasePlatformAdapter):
if uid.strip()
}
# Set up intents.
# Message Content is required for normal text replies.
# Server Members is only needed when the allowlist contains usernames
# that must be resolved to numeric IDs. Requesting privileged intents
# that aren't enabled in the Discord Developer Portal can prevent the
# bot from coming online at all, so avoid requesting members intent
# unless it is actually necessary.
intents = Intents.default()
intents.message_content = True
intents.dm_messages = True
intents.guild_messages = True
intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids)
intents.voice_states = True
# Create bot
self._client = commands.Bot(
command_prefix="!", # Not really used, we handle raw messages
intents=intents,
)
adapter_self = self # capture for closure
# Register event handlers
@@ -539,6 +550,19 @@ class DiscordAdapter(BasePlatformAdapter):
@self._client.event
async def on_message(message: DiscordMessage):
# Dedup: Discord RESUME replays events after reconnects (#4777)
msg_id = str(message.id)
now = time.time()
if msg_id in adapter_self._seen_messages:
return
adapter_self._seen_messages[msg_id] = now
if len(adapter_self._seen_messages) > adapter_self._SEEN_MAX:
cutoff = now - adapter_self._SEEN_TTL
adapter_self._seen_messages = {
k: v for k, v in adapter_self._seen_messages.items()
if v > cutoff
}
# Always ignore our own messages
if message.author == self._client.user:
return
@@ -630,9 +654,23 @@ class DiscordAdapter(BasePlatformAdapter):
except asyncio.TimeoutError:
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
try:
from gateway.status import release_scoped_lock
if getattr(self, '_token_lock_identity', None):
release_scoped_lock('discord-bot-token', self._token_lock_identity)
self._token_lock_identity = None
except Exception:
pass
return False
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
try:
from gateway.status import release_scoped_lock
if getattr(self, '_token_lock_identity', None):
release_scoped_lock('discord-bot-token', self._token_lock_identity)
self._token_lock_identity = None
except Exception:
pass
return False
async def disconnect(self) -> None:
@@ -1617,6 +1655,16 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_update(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/update", "Update initiated~")
@tree.command(name="approve", description="Approve a pending dangerous command")
@discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'")
async def slash_approve(interaction: discord.Interaction, scope: str = ""):
await self._run_simple_slash(interaction, f"/approve {scope}".strip())
@tree.command(name="deny", description="Deny a pending dangerous command")
@discord.app_commands.describe(scope="Optional: 'all' to deny all pending commands")
async def slash_deny(interaction: discord.Interaction, scope: str = ""):
await self._run_simple_slash(interaction, f"/deny {scope}".strip())
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
@discord.app_commands.describe(
name="Thread name",
@@ -1632,6 +1680,62 @@ class DiscordAdapter(BasePlatformAdapter):
await interaction.response.defer(ephemeral=True)
await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
@tree.command(name="queue", description="Queue a prompt for the next turn (doesn't interrupt)")
@discord.app_commands.describe(prompt="The prompt to queue")
async def slash_queue(interaction: discord.Interaction, prompt: str):
await self._run_simple_slash(interaction, f"/queue {prompt}", "Queued for the next turn.")
@tree.command(name="background", description="Run a prompt in the background")
@discord.app_commands.describe(prompt="The prompt to run in the background")
async def slash_background(interaction: discord.Interaction, prompt: str):
await self._run_simple_slash(interaction, f"/background {prompt}", "Background task started~")
@tree.command(name="btw", description="Ephemeral side question using session context")
@discord.app_commands.describe(question="Your side question (no tools, not persisted)")
async def slash_btw(interaction: discord.Interaction, question: str):
await self._run_simple_slash(interaction, f"/btw {question}")
# Register installed skills as native slash commands (parity with
# Telegram, which uses telegram_menu_commands() in commands.py).
# Discord allows up to 100 application commands globally.
_DISCORD_CMD_LIMIT = 100
try:
from hermes_cli.commands import discord_skill_commands
existing_names = {cmd.name for cmd in tree.get_commands()}
remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names))
skill_entries, skipped = discord_skill_commands(
max_slots=remaining_slots,
reserved_names=existing_names,
)
for discord_name, description, cmd_key in skill_entries:
# Closure factory to capture cmd_key per iteration
def _make_skill_handler(_key: str):
async def _skill_slash(interaction: discord.Interaction, args: str = ""):
await self._run_simple_slash(interaction, f"{_key} {args}".strip())
return _skill_slash
handler = _make_skill_handler(cmd_key)
handler.__name__ = f"skill_{discord_name.replace('-', '_')}"
cmd = discord.app_commands.Command(
name=discord_name,
description=description,
callback=handler,
)
discord.app_commands.describe(args="Optional arguments for the skill")(cmd)
tree.add_command(cmd)
if skipped:
logger.warning(
"[%s] Discord slash command limit reached (%d): %d skill(s) not registered",
self.name, _DISCORD_CMD_LIMIT, skipped,
)
except Exception as exc:
logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc)
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
"""Build a MessageEvent from a Discord slash command interaction."""
is_dm = isinstance(interaction.channel, discord.DMChannel)
@@ -1860,33 +1964,41 @@ class DiscordAdapter(BasePlatformAdapter):
return None
async def send_exec_approval(
self, chat_id: str, command: str, approval_id: str
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
metadata: Optional[dict] = None,
) -> SendResult:
"""
Send a button-based exec approval prompt for a dangerous command.
Returns SendResult. The approval is resolved when a user clicks a button.
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
agent thread this replaces the text-based ``/approve`` flow on Discord.
"""
if not self._client or not DISCORD_AVAILABLE:
return SendResult(success=False, error="Not connected")
try:
channel = self._client.get_channel(int(chat_id))
# Resolve channel — use thread_id from metadata if present
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(chat_id))
channel = await self._client.fetch_channel(int(target_id))
# Discord embed description limit is 4096; show full command up to that
max_desc = 4088
cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..."
embed = discord.Embed(
title="Command Approval Required",
title="⚠️ Command Approval Required",
description=f"```\n{cmd_display}\n```",
color=discord.Color.orange(),
)
embed.set_footer(text=f"Approval ID: {approval_id}")
embed.add_field(name="Reason", value=description, inline=False)
view = ExecApprovalView(
approval_id=approval_id,
session_key=session_key,
allowed_user_ids=self._allowed_user_ids,
)
@@ -1896,6 +2008,37 @@ class DiscordAdapter(BasePlatformAdapter):
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 = "",
) -> SendResult:
"""Send an interactive button-based update prompt (Yes / No).
Used by the gateway ``/update`` watcher when ``hermes update --gateway``
needs user input (stash restore, config migration).
"""
if not self._client or not DISCORD_AVAILABLE:
return SendResult(success=False, error="Not connected")
try:
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
default_hint = f" (default: {default})" if default else ""
embed = discord.Embed(
title="⚕ Update Needs Your Input",
description=f"{prompt}{default_hint}",
color=discord.Color.gold(),
)
view = UpdatePromptView(
session_key=session_key,
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))
def _get_parent_channel_id(self, channel: Any) -> Optional[str]:
"""Return the parent channel ID for a Discord thread-like channel, if present."""
parent = getattr(channel, "parent", None)
@@ -2219,13 +2362,15 @@ if DISCORD_AVAILABLE:
"""
Interactive button view for exec approval of dangerous commands.
Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red).
Only users in the allowed list can click. The view times out after 5 minutes.
Shows four buttons: Allow Once, Allow Session, Always Allow, Deny.
Clicking a button calls ``resolve_gateway_approval()`` to unblock the
waiting agent thread the same mechanism as the text ``/approve`` flow.
Only users in the allowed list can click. Times out after 5 minutes.
"""
def __init__(self, approval_id: str, allowed_user_ids: set):
def __init__(self, session_key: str, allowed_user_ids: set):
super().__init__(timeout=300) # 5-minute timeout
self.approval_id = approval_id
self.session_key = session_key
self.allowed_user_ids = allowed_user_ids
self.resolved = False
@@ -2236,9 +2381,10 @@ if DISCORD_AVAILABLE:
return str(interaction.user.id) in self.allowed_user_ids
async def _resolve(
self, interaction: discord.Interaction, action: str, color: discord.Color
self, interaction: discord.Interaction, choice: str,
color: discord.Color, label: str,
):
"""Resolve the approval and update the message."""
"""Resolve the approval via the gateway approval queue and update the embed."""
if self.resolved:
await interaction.response.send_message(
"This approval has already been resolved~", ephemeral=True
@@ -2257,7 +2403,7 @@ if DISCORD_AVAILABLE:
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = color
embed.set_footer(text=f"{action} by {interaction.user.display_name}")
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
# Disable all buttons
for child in self.children:
@@ -2265,36 +2411,122 @@ if DISCORD_AVAILABLE:
await interaction.response.edit_message(embed=embed, view=self)
# Store the approval decision
# Unblock the waiting agent thread via the gateway approval queue
try:
from tools.approval import approve_permanent
if action == "allow_once":
pass # One-time approval handled by gateway
elif action == "allow_always":
approve_permanent(self.approval_id)
except ImportError:
pass
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(self.session_key, choice)
logger.info(
"Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
count, self.session_key, choice, interaction.user.display_name,
)
except Exception as exc:
logger.error("Failed to resolve gateway approval from button: %s", exc)
@discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green)
async def allow_once(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._resolve(interaction, "allow_once", discord.Color.green())
await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
@discord.ui.button(label="Allow Session", style=discord.ButtonStyle.grey)
async def allow_session(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._resolve(interaction, "session", discord.Color.blue(), "Approved for session")
@discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple)
async def allow_always(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._resolve(interaction, "allow_always", discord.Color.blue())
await self._resolve(interaction, "always", discord.Color.purple(), "Approved permanently")
@discord.ui.button(label="Deny", style=discord.ButtonStyle.red)
async def deny(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._resolve(interaction, "deny", discord.Color.red())
await self._resolve(interaction, "deny", discord.Color.red(), "Denied")
async def on_timeout(self):
"""Handle view timeout -- disable buttons and mark as expired."""
self.resolved = True
for child in self.children:
child.disabled = True
class UpdatePromptView(discord.ui.View):
"""Interactive Yes/No buttons for ``hermes update`` prompts.
Clicking a button writes the answer to ``.update_response`` so the
detached update process can pick it up. Only authorized users can
click. Times out after 5 minutes (the update process also has a
5-minute timeout on its side).
"""
def __init__(self, session_key: str, allowed_user_ids: set):
super().__init__(timeout=300)
self.session_key = session_key
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 _respond(
self, interaction: discord.Interaction, answer: str,
color: discord.Color, label: str,
):
if self.resolved:
await interaction.response.send_message(
"Already answered~", ephemeral=True
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
self.resolved = True
# Update embed
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)
# Write response file
try:
from hermes_constants import get_hermes_home
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.replace(response_path)
logger.info(
"Discord update prompt answered '%s' by %s",
answer, interaction.user.display_name,
)
except Exception as exc:
logger.error("Failed to write update response: %s", exc)
@discord.ui.button(label="Yes", style=discord.ButtonStyle.green, emoji="")
async def yes_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._respond(interaction, "y", discord.Color.green(), "Yes")
@discord.ui.button(label="No", style=discord.ButtonStyle.red, emoji="")
async def no_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._respond(interaction, "n", discord.Color.red(), "No")
async def on_timeout(self):
self.resolved = True
for child in self.children:
child.disabled = True
+213 -23
View File
@@ -270,6 +270,22 @@ class FeishuAdapterSettings:
webhook_host: str
webhook_port: int
webhook_path: str
ws_reconnect_nonce: int = 30
ws_reconnect_interval: int = 120
ws_ping_interval: Optional[int] = None
ws_ping_timeout: Optional[int] = None
admins: frozenset[str] = frozenset()
default_group_policy: str = ""
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
@dataclass
class FeishuGroupRule:
"""Per-group policy rule for controlling which users may interact with the bot."""
policy: str # "open" | "allowlist" | "blacklist" | "admin_only" | "disabled"
allowlist: set[str] = field(default_factory=set)
blacklist: set[str] = field(default_factory=set)
@dataclass
@@ -358,6 +374,24 @@ def _strip_markdown_to_plain_text(text: str) -> str:
return plain.strip()
def _coerce_int(value: Any, default: Optional[int] = None, min_value: int = 0) -> Optional[int]:
"""Coerce value to int with optional default and minimum constraint."""
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return parsed if parsed >= min_value else default
def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:
parsed = _coerce_int(value, default=default, min_value=min_value)
return default if parsed is None else parsed
def _is_loop_ready(loop: Optional[asyncio.AbstractEventLoop]) -> bool:
return loop is not None and not bool(getattr(loop, "is_closed", lambda: False)())
# ---------------------------------------------------------------------------
# Post payload builders and parsers
# ---------------------------------------------------------------------------
@@ -913,14 +947,66 @@ def _unique_lines(lines: List[str]) -> List[str]:
return unique
def _run_official_feishu_ws_client(ws_client: Any) -> None:
def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
"""Run the official Lark WS client in its own thread-local event loop."""
import lark_oapi.ws.client as ws_client_module
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
ws_client_module.loop = loop
ws_client.start()
adapter._ws_thread_loop = loop
original_connect = ws_client_module.websockets.connect
original_configure = getattr(ws_client, "_configure", None)
def _apply_runtime_ws_overrides() -> None:
try:
setattr(ws_client, "_reconnect_nonce", adapter._ws_reconnect_nonce)
setattr(ws_client, "_reconnect_interval", adapter._ws_reconnect_interval)
if adapter._ws_ping_interval is not None:
setattr(ws_client, "_ping_interval", adapter._ws_ping_interval)
except Exception:
logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True)
async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any:
if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs:
kwargs["ping_interval"] = adapter._ws_ping_interval
if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs:
kwargs["ping_timeout"] = adapter._ws_ping_timeout
return await original_connect(*args, **kwargs)
def _configure_with_overrides(conf: Any) -> Any:
assert original_configure is not None
result = original_configure(conf)
_apply_runtime_ws_overrides()
return result
ws_client_module.websockets.connect = _connect_with_overrides
if original_configure is not None:
setattr(ws_client, "_configure", _configure_with_overrides)
_apply_runtime_ws_overrides()
try:
ws_client.start()
except Exception:
pass
finally:
ws_client_module.websockets.connect = original_connect
if original_configure is not None:
setattr(ws_client, "_configure", original_configure)
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
try:
loop.stop()
except Exception:
pass
try:
loop.close()
except Exception:
pass
adapter._ws_thread_loop = None
def check_feishu_requirements() -> bool:
@@ -945,10 +1031,11 @@ class FeishuAdapter(BasePlatformAdapter):
self._client: Optional[Any] = None
self._ws_client: Optional[Any] = None
self._ws_future: Optional[asyncio.Future] = None
self._ws_thread_loop: Optional[asyncio.AbstractEventLoop] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._webhook_runner: Optional[Any] = None
self._webhook_site: Optional[Any] = None
self._event_handler = self._build_event_handler()
self._event_handler: Optional[Any] = None
self._seen_message_ids: Dict[str, float] = {} # message_id → seen_at (time.time())
self._seen_message_order: List[str] = []
self._dedup_state_path = get_hermes_home() / "feishu_seen_message_ids.json"
@@ -974,6 +1061,26 @@ class FeishuAdapter(BasePlatformAdapter):
@staticmethod
def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
# Parse per-group rules from config
raw_group_rules = extra.get("group_rules", {})
group_rules: Dict[str, FeishuGroupRule] = {}
if isinstance(raw_group_rules, dict):
for chat_id, rule_cfg in raw_group_rules.items():
if not isinstance(rule_cfg, dict):
continue
group_rules[str(chat_id)] = FeishuGroupRule(
policy=str(rule_cfg.get("policy", "open")).strip().lower(),
allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()),
blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()),
)
# Bot-level admins
raw_admins = extra.get("admins", [])
admins = frozenset(str(u).strip() for u in raw_admins if str(u).strip())
# Default group policy (for groups not in group_rules)
default_group_policy = str(extra.get("default_group_policy", "")).strip().lower()
return FeishuAdapterSettings(
app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(),
app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(),
@@ -1020,6 +1127,13 @@ class FeishuAdapter(BasePlatformAdapter):
str(extra.get("webhook_path") or os.getenv("FEISHU_WEBHOOK_PATH", _DEFAULT_WEBHOOK_PATH)).strip()
or _DEFAULT_WEBHOOK_PATH
),
ws_reconnect_nonce=_coerce_required_int(extra.get("ws_reconnect_nonce"), default=30, min_value=0),
ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1),
ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1),
ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1),
admins=admins,
default_group_policy=default_group_policy,
group_rules=group_rules,
)
def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
@@ -1031,6 +1145,9 @@ class FeishuAdapter(BasePlatformAdapter):
self._verification_token = settings.verification_token
self._group_policy = settings.group_policy
self._allowed_group_users = set(settings.allowed_group_users)
self._admins = set(settings.admins)
self._default_group_policy = settings.default_group_policy or settings.group_policy
self._group_rules = settings.group_rules
self._bot_open_id = settings.bot_open_id
self._bot_user_id = settings.bot_user_id
self._bot_name = settings.bot_name
@@ -1042,6 +1159,10 @@ class FeishuAdapter(BasePlatformAdapter):
self._webhook_host = settings.webhook_host
self._webhook_port = settings.webhook_port
self._webhook_path = settings.webhook_path
self._ws_reconnect_nonce = settings.ws_reconnect_nonce
self._ws_reconnect_interval = settings.ws_reconnect_interval
self._ws_ping_interval = settings.ws_ping_interval
self._ws_ping_timeout = settings.ws_ping_timeout
def _build_event_handler(self) -> Any:
if EventDispatcherHandler is None:
@@ -1116,8 +1237,37 @@ class FeishuAdapter(BasePlatformAdapter):
self._reset_batch_buffers()
self._disable_websocket_auto_reconnect()
await self._stop_webhook_server()
ws_thread_loop = self._ws_thread_loop
if ws_thread_loop is not None and not ws_thread_loop.is_closed():
logger.debug("[Feishu] Cancelling websocket thread tasks and stopping loop")
def cancel_all_tasks() -> None:
tasks = [t for t in asyncio.all_tasks(ws_thread_loop) if not t.done()]
logger.debug("[Feishu] Found %d pending tasks in websocket thread", len(tasks))
for task in tasks:
task.cancel()
ws_thread_loop.call_later(0.1, ws_thread_loop.stop)
ws_thread_loop.call_soon_threadsafe(cancel_all_tasks)
ws_future = self._ws_future
if ws_future is not None:
try:
logger.debug("[Feishu] Waiting for websocket thread to exit (timeout=10s)")
await asyncio.wait_for(asyncio.shield(ws_future), timeout=10.0)
logger.debug("[Feishu] Websocket thread exited cleanly")
except asyncio.TimeoutError:
logger.warning("[Feishu] Websocket thread did not exit within 10s - may be stuck")
except asyncio.CancelledError:
logger.debug("[Feishu] Websocket thread cancelled during disconnect")
except Exception as exc:
logger.debug("[Feishu] Websocket thread exited with error: %s", exc, exc_info=True)
self._ws_future = None
self._ws_thread_loop = None
self._loop = None
self._event_handler = None
self._persist_seen_message_ids()
await self._release_app_lock()
@@ -1476,12 +1626,13 @@ class FeishuAdapter(BasePlatformAdapter):
def _on_message_event(self, data: Any) -> None:
"""Normalize Feishu inbound events into MessageEvent."""
if self._loop is None:
loop = self._loop
if loop is None or bool(getattr(loop, "is_closed", lambda: False)()):
logger.warning("[Feishu] Dropping inbound message before adapter loop is ready")
return
future = asyncio.run_coroutine_threadsafe(
self._handle_message_event_data(data),
self._loop,
loop,
)
future.add_done_callback(self._log_background_failure)
@@ -1504,7 +1655,8 @@ class FeishuAdapter(BasePlatformAdapter):
return
chat_type = getattr(message, "chat_type", "p2p")
if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id):
chat_id = getattr(message, "chat_id", "") or ""
if chat_type != "p2p" and not self._should_accept_group_message(message, sender_id, chat_id):
logger.debug("[Feishu] Dropping group message that failed mention/policy gate: %s", message_id)
return
await self._process_inbound_message(
@@ -1553,27 +1705,30 @@ class FeishuAdapter(BasePlatformAdapter):
)
# Only process reactions from real users. Ignore app/bot-generated reactions
# and Hermes' own ACK emoji to avoid feedback loops.
loop = self._loop
if (
operator_type in {"bot", "app"}
or emoji_type == _FEISHU_ACK_EMOJI
or not message_id
or self._loop is None
or loop is None
or bool(getattr(loop, "is_closed", lambda: False)())
):
return
future = asyncio.run_coroutine_threadsafe(
self._handle_reaction_event(event_type, data),
self._loop,
loop,
)
future.add_done_callback(self._log_background_failure)
def _on_card_action_trigger(self, data: Any) -> Any:
"""Schedule Feishu card actions on the adapter loop and acknowledge immediately."""
if self._loop is None:
loop = self._loop
if loop is None or bool(getattr(loop, "is_closed", lambda: False)()):
logger.warning("[Feishu] Dropping card action before adapter loop is ready")
else:
future = asyncio.run_coroutine_threadsafe(
self._handle_card_action_event(data),
self._loop,
loop,
)
future.add_done_callback(self._log_background_failure)
if P2CardActionTriggerResponse is None:
@@ -1887,6 +2042,7 @@ class FeishuAdapter(BasePlatformAdapter):
session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
return f"{session_key}:media:{event.message_type.value}"
@@ -2082,7 +2238,7 @@ class FeishuAdapter(BasePlatformAdapter):
event_type = str((payload.get("header") or {}).get("event_type") or "")
data = self._namespace_from_mapping(payload)
if event_type == "im.message.receive_v1":
await self._handle_message_event_data(data)
self._on_message_event(data)
elif event_type == "im.message.message_read_v1":
self._on_message_read_event(data)
elif event_type == "im.chat.member.bot.added_v1":
@@ -2092,7 +2248,7 @@ class FeishuAdapter(BasePlatformAdapter):
elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"):
self._on_reaction_event(event_type, data)
elif event_type == "card.action.trigger":
asyncio.ensure_future(self._handle_card_action_event(data))
self._on_card_action_trigger(data)
else:
logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
return web.json_response({"code": 0, "msg": "ok"})
@@ -2163,6 +2319,7 @@ class FeishuAdapter(BasePlatformAdapter):
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
@staticmethod
@@ -2655,18 +2812,41 @@ class FeishuAdapter(BasePlatformAdapter):
# Group policy and mention gating
# =========================================================================
def _allow_group_message(self, sender_id: Any) -> bool:
"""Current group policy gate for non-DM traffic."""
if self._group_policy == "disabled":
return False
sender_open_id = getattr(sender_id, "open_id", None) or getattr(sender_id, "user_id", None)
if self._group_policy == "open":
return True
return bool(sender_open_id and sender_open_id in self._allowed_group_users)
def _allow_group_message(self, sender_id: Any, chat_id: str = "") -> bool:
"""Per-group policy gate for non-DM traffic."""
sender_open_id = getattr(sender_id, "open_id", None)
sender_user_id = getattr(sender_id, "user_id", None)
sender_ids = {sender_open_id, sender_user_id} - {None}
def _should_accept_group_message(self, message: Any, sender_id: Any) -> bool:
if sender_ids and self._admins and (sender_ids & self._admins):
return True
rule = self._group_rules.get(chat_id) if chat_id else None
if rule:
policy = rule.policy
allowlist = rule.allowlist
blacklist = rule.blacklist
else:
policy = self._default_group_policy or self._group_policy
allowlist = self._allowed_group_users
blacklist = set()
if policy == "disabled":
return False
if policy == "open":
return True
if policy == "admin_only":
return False
if policy == "allowlist":
return bool(sender_ids and (sender_ids & allowlist))
if policy == "blacklist":
return bool(sender_ids and not (sender_ids & blacklist))
return bool(sender_ids and (sender_ids & self._allowed_group_users))
def _should_accept_group_message(self, message: Any, sender_id: Any, chat_id: str = "") -> bool:
"""Require an explicit @mention before group messages enter the agent."""
if not self._allow_group_message(sender_id):
if not self._allow_group_message(sender_id, chat_id):
return False
# @_all is Feishu's @everyone placeholder — always route to the bot.
raw_content = getattr(message, "content", "") or ""
@@ -2963,6 +3143,12 @@ class FeishuAdapter(BasePlatformAdapter):
raise RuntimeError("websockets not installed; websocket mode unavailable")
domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
self._client = self._build_lark_client(domain)
self._event_handler = self._build_event_handler()
if self._event_handler is None:
raise RuntimeError("failed to build Feishu event handler")
loop = self._loop
if loop is None or loop.is_closed():
raise RuntimeError("adapter loop is not ready")
await self._hydrate_bot_identity()
self._ws_client = FeishuWSClient(
app_id=self._app_id,
@@ -2971,10 +3157,11 @@ class FeishuAdapter(BasePlatformAdapter):
event_handler=self._event_handler,
domain=domain,
)
self._ws_future = self._loop.run_in_executor(
self._ws_future = loop.run_in_executor(
None,
_run_official_feishu_ws_client,
self._ws_client,
self,
)
async def _connect_webhook(self) -> None:
@@ -2982,6 +3169,9 @@ class FeishuAdapter(BasePlatformAdapter):
raise RuntimeError("aiohttp not installed; webhook mode unavailable")
domain = FEISHU_DOMAIN if self._domain_name != "lark" else LARK_DOMAIN
self._client = self._build_lark_client(domain)
self._event_handler = self._build_event_handler()
if self._event_handler is None:
raise RuntimeError("failed to build Feishu event handler")
await self._hydrate_bot_identity()
app = web.Application()
app.router.add_post(self._webhook_path, self._handle_webhook_request)
File diff suppressed because it is too large Load Diff
+19
View File
@@ -513,6 +513,16 @@ class MattermostAdapter(BasePlatformAdapter):
except Exception as exc:
if self._closing:
return
# Detect permanent auth/permission failures that will never
# succeed on retry — stop reconnecting instead of looping forever.
import aiohttp
err_str = str(exc).lower()
if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in (401, 403):
logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status)
return
if "401" in err_str or "403" in err_str or "unauthorized" in err_str:
logger.error("Mattermost WS permanent error: %s — stopping reconnect", exc)
return
logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay)
if self._closing:
@@ -691,6 +701,15 @@ class MattermostAdapter(BasePlatformAdapter):
except Exception as exc:
logger.warning("Mattermost: error downloading file %s: %s", fid, exc)
# Set message type based on downloaded media types.
if media_types and msg_type == MessageType.TEXT:
if any(m.startswith("image/") for m in media_types):
msg_type = MessageType.PHOTO
elif any(m.startswith("audio/") for m in media_types):
msg_type = MessageType.VOICE
elif media_types:
msg_type = MessageType.DOCUMENT
source = self.build_source(
chat_id=channel_id,
chat_type=chat_type,
+67 -7
View File
@@ -717,19 +717,27 @@ class SignalAdapter(BasePlatformAdapter):
return SendResult(success=True)
return SendResult(success=False, error="RPC send with attachment failed")
async def send_document(
async def _send_attachment(
self,
chat_id: str,
file_path: str,
media_label: str,
caption: Optional[str] = None,
filename: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send a document/file attachment."""
"""Send any file as a Signal attachment via RPC.
Shared implementation for send_document, send_image_file, send_voice,
and send_video avoids duplicating the validation/routing/RPC logic.
"""
await self._stop_typing_indicator(chat_id)
if not Path(file_path).exists():
return SendResult(success=False, error="File not found")
try:
file_size = Path(file_path).stat().st_size
except FileNotFoundError:
return SendResult(success=False, error=f"{media_label} file not found: {file_path}")
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
return SendResult(success=False, error=f"{media_label} too large ({file_size} bytes)")
params: Dict[str, Any] = {
"account": self.account,
@@ -746,7 +754,59 @@ class SignalAdapter(BasePlatformAdapter):
if result is not None:
self._track_sent_timestamp(result)
return SendResult(success=True)
return SendResult(success=False, error="RPC send document failed")
return SendResult(success=False, error=f"RPC send {media_label.lower()} failed")
async def send_document(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
filename: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send a document/file attachment."""
return await self._send_attachment(chat_id, file_path, "File", caption)
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send a local image file as a native Signal attachment.
Called by the gateway media delivery flow when MEDIA: tags containing
image paths are extracted from agent responses.
"""
return await self._send_attachment(chat_id, image_path, "Image", caption)
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send an audio file as a Signal attachment.
Signal does not distinguish voice messages from file attachments at
the API level, so this routes through the same RPC send path.
"""
return await self._send_attachment(chat_id, audio_path, "Audio", caption)
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send a video file as a Signal attachment."""
return await self._send_attachment(chat_id, video_path, "Video", caption)
# ------------------------------------------------------------------
# Typing Indicators
+31
View File
@@ -13,6 +13,7 @@ import json
import logging
import os
import re
import time
from typing import Dict, Optional, Any
try:
@@ -78,6 +79,11 @@ class SlackAdapter(BasePlatformAdapter):
self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
self._channel_team: Dict[str, str] = {} # channel_id → team_id
# Dedup cache: event_ts → timestamp. Prevents duplicate bot
# responses when Socket Mode reconnects redeliver events.
self._seen_messages: Dict[str, float] = {}
self._SEEN_TTL = 300 # 5 minutes
self._SEEN_MAX = 2000 # prune threshold
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
@@ -323,7 +329,18 @@ class SlackAdapter(BasePlatformAdapter):
Prefers metadata thread_id (the thread parent's ts, set by the
gateway) over reply_to (which may be a child message's ts).
When ``reply_in_thread`` is ``false`` in the platform extra config,
top-level channel messages receive direct channel replies instead of
thread replies. Messages that originate inside an existing thread are
always replied to in-thread to preserve conversation context.
"""
# When reply_in_thread is disabled (default: True for backward compat),
# only thread messages that are already part of an existing thread.
if not self.config.extra.get("reply_in_thread", True):
existing_thread = (metadata or {}).get("thread_id") or (metadata or {}).get("thread_ts")
return existing_thread or None
if metadata:
if metadata.get("thread_id"):
return metadata["thread_id"]
@@ -699,6 +716,20 @@ class SlackAdapter(BasePlatformAdapter):
async def _handle_slack_message(self, event: dict) -> None:
"""Handle an incoming Slack message event."""
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
event_ts = event.get("ts", "")
if event_ts:
now = time.time()
if event_ts in self._seen_messages:
return
self._seen_messages[event_ts] = now
if len(self._seen_messages) > self._SEEN_MAX:
cutoff = now - self._SEEN_TTL
self._seen_messages = {
k: v for k, v in self._seen_messages.items()
if v > cutoff
}
# Ignore bot messages (including our own)
if event.get("bot_id") or event.get("subtype") == "bot_message":
return
+131 -4
View File
@@ -17,10 +17,11 @@ from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
try:
from telegram import Update, Bot, Message
from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application,
CommandHandler,
CallbackQueryHandler,
MessageHandler as TelegramMessageHandler,
ContextTypes,
filters,
@@ -33,8 +34,11 @@ except ImportError:
Update = Any
Bot = Any
Message = Any
InlineKeyboardButton = Any
InlineKeyboardMarkup = Any
Application = Any
CommandHandler = Any
CallbackQueryHandler = Any
TelegramMessageHandler = Any
HTTPXRequest = Any
filters = None
@@ -514,7 +518,7 @@ class TelegramAdapter(BasePlatformAdapter):
", ".join(fallback_ips),
)
if fallback_ips:
logger.warning(
logger.info(
"[%s] Telegram fallback IPs active: %s",
self.name,
", ".join(fallback_ips),
@@ -543,6 +547,8 @@ class TelegramAdapter(BasePlatformAdapter):
filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL,
self._handle_media_message
))
# Handle inline keyboard button callbacks (update prompts)
self._app.add_handler(CallbackQueryHandler(self._handle_callback_query))
# Start polling — retry initialize() for transient TLS resets
try:
@@ -595,6 +601,12 @@ class TelegramAdapter(BasePlatformAdapter):
)
else:
# ── Polling mode (default) ───────────────────────────
# Clear any stale webhook first so polling doesn't inherit a
# previous webhook registration and silently stop receiving updates.
delete_webhook = getattr(self._bot, "delete_webhook", None)
if callable(delete_webhook):
await delete_webhook(drop_pending_updates=False)
loop = asyncio.get_running_loop()
def _polling_error_callback(error: Exception) -> None:
@@ -772,6 +784,11 @@ class TelegramAdapter(BasePlatformAdapter):
except ImportError:
_BadReq = None # type: ignore[assignment,misc]
try:
from telegram.error import TimedOut as _TimedOut
except (ImportError, AttributeError):
_TimedOut = None # type: ignore[assignment,misc]
for i, chunk in enumerate(chunks):
should_thread = self._should_thread_reply(reply_to, i)
reply_to_id = int(reply_to) if should_thread else None
@@ -833,6 +850,11 @@ class TelegramAdapter(BasePlatformAdapter):
continue
# Other BadRequest errors are permanent — don't retry
raise
# TimedOut is also a subclass of NetworkError but
# indicates the request may have reached the server —
# retrying risks duplicate message delivery.
if _TimedOut and isinstance(send_err, _TimedOut):
raise
if _send_attempt < 2:
wait = 2 ** _send_attempt
logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
@@ -840,6 +862,21 @@ class TelegramAdapter(BasePlatformAdapter):
await asyncio.sleep(wait)
else:
raise
except Exception as send_err:
retry_after = getattr(send_err, "retry_after", None)
if retry_after is not None or "retry after" in str(send_err).lower():
if _send_attempt < 2:
wait = float(retry_after) if retry_after is not None else 1.0
logger.warning(
"[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s",
self.name,
_send_attempt + 1,
wait,
send_err,
)
await asyncio.sleep(wait)
continue
raise
message_ids.append(str(msg.message_id))
return SendResult(
@@ -850,7 +887,12 @@ class TelegramAdapter(BasePlatformAdapter):
except Exception as e:
logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True)
return SendResult(success=False, error=str(e))
# TimedOut means the request may have reached Telegram —
# mark as non-retryable so _send_with_retry() doesn't re-send.
_to = locals().get("_TimedOut")
err_str = str(e).lower()
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
return SendResult(success=False, error=str(e), retryable=not is_timeout)
async def edit_message(
self,
@@ -900,7 +942,9 @@ class TelegramAdapter(BasePlatformAdapter):
except Exception:
pass # best-effort truncation
return SendResult(success=True, message_id=message_id)
# Flood control / RetryAfter — back off and retry once
# Flood control / RetryAfter — short waits are retried inline,
# long waits return a failure immediately so streaming can fall back
# to a normal final send instead of leaving a truncated partial.
retry_after = getattr(e, "retry_after", None)
if retry_after is not None or "retry after" in err_str:
wait = retry_after if retry_after else 1.0
@@ -908,6 +952,8 @@ class TelegramAdapter(BasePlatformAdapter):
"[%s] Telegram flood control, waiting %.1fs",
self.name, wait,
)
if wait > 5.0:
return SendResult(success=False, error=f"flood_control:{wait}")
await asyncio.sleep(wait)
try:
await self._bot.edit_message_text(
@@ -931,6 +977,72 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=False, error=str(e))
async def send_update_prompt(
self, chat_id: str, prompt: str, default: str = "",
session_key: str = "",
) -> SendResult:
"""Send an inline-keyboard update prompt (Yes / No buttons).
Used by the gateway ``/update`` watcher when ``hermes update --gateway``
needs user input (stash restore, config migration).
"""
if not self._bot:
return SendResult(success=False, error="Not connected")
try:
default_hint = f" (default: {default})" if default else ""
text = f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}"
keyboard = InlineKeyboardMarkup([
[
InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
InlineKeyboardButton("✗ No", callback_data="update_prompt:n"),
]
])
msg = await self._bot.send_message(
chat_id=int(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN,
reply_markup=keyboard,
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
return SendResult(success=False, error=str(e))
async def _handle_callback_query(
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
) -> None:
"""Handle inline keyboard button clicks (update prompts)."""
query = update.callback_query
if not query or not query.data:
return
data = query.data
if not data.startswith("update_prompt:"):
return
answer = data.split(":", 1)[1] # "y" or "n"
await query.answer(text=f"Sent '{answer}' to the update process.")
# Edit the message to show the choice and remove buttons
label = "Yes" if answer == "y" else "No"
try:
await query.edit_message_text(
text=f"⚕ Update prompt answered: *{label}*",
parse_mode=ParseMode.MARKDOWN,
reply_markup=None,
)
except Exception:
pass # non-fatal if edit fails
# Write the response file
try:
from hermes_constants import get_hermes_home
home = get_hermes_home()
response_path = home / ".update_response"
tmp = response_path.with_suffix(".tmp")
tmp.write_text(answer)
tmp.replace(response_path)
logger.info("Telegram update prompt answered '%s' by user %s",
answer, getattr(query.from_user, "id", "unknown"))
except Exception as exc:
logger.error("Failed to write update response from callback: %s", exc)
async def send_voice(
self,
chat_id: str,
@@ -1599,6 +1711,7 @@ class TelegramAdapter(BasePlatformAdapter):
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
@@ -1657,6 +1770,7 @@ class TelegramAdapter(BasePlatformAdapter):
session_key = build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
media_group_id = getattr(msg, "media_group_id", None)
if media_group_id:
@@ -2097,6 +2211,19 @@ class TelegramAdapter(BasePlatformAdapter):
if not chat_topic:
chat_topic = created_name
elif chat_type == "group" and thread_id_str:
# Group/supergroup forum topic skill binding via config.extra['group_topics']
group_topics_config: list = self.config.extra.get("group_topics", [])
for chat_entry in group_topics_config:
if str(chat_entry.get("chat_id", "")) == str(chat.id):
for topic in chat_entry.get("topics", []):
tid = topic.get("thread_id")
if tid is not None and str(tid) == thread_id_str:
chat_topic = topic.get("name")
topic_skill = topic.get("skill")
break
break
# Build source
source = self.build_source(
chat_id=str(chat.id),
+14 -1
View File
@@ -484,6 +484,10 @@ class WebhookAdapter(BasePlatformAdapter):
Supports dot-notation access into nested dicts:
``{pull_request.title}`` ``payload["pull_request"]["title"]``
Special token ``{__raw__}`` dumps the entire payload as indented
JSON (truncated to 4000 chars). Useful for monitoring alerts or
any webhook where the agent needs to see the full payload.
"""
if not template:
truncated = json.dumps(payload, indent=2)[:4000]
@@ -494,6 +498,9 @@ class WebhookAdapter(BasePlatformAdapter):
def _resolve(match: re.Match) -> str:
key = match.group(1)
# Special token: dump the entire payload as JSON
if key == "__raw__":
return json.dumps(payload, indent=2)[:4000]
value: Any = payload
for part in key.split("."):
if isinstance(value, dict):
@@ -613,4 +620,10 @@ class WebhookAdapter(BasePlatformAdapter):
error=f"No chat_id or home channel for {platform_name}",
)
return await adapter.send(chat_id, content)
# Pass thread_id from deliver_extra so Telegram forum topics work
metadata = None
thread_id = extra.get("message_thread_id") or extra.get("thread_id")
if thread_id:
metadata = {"thread_id": thread_id}
return await adapter.send(chat_id, content, metadata=metadata)
+132
View File
@@ -16,9 +16,11 @@ with different backends via a bridge pattern.
"""
import asyncio
import json
import logging
import os
import platform
import re
import subprocess
_IS_WINDOWS = platform.system() == "Windows"
@@ -138,12 +140,137 @@ class WhatsAppAdapter(BasePlatformAdapter):
get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")
))
self._reply_prefix: Optional[str] = config.extra.get("reply_prefix")
self._mention_patterns = self._compile_mention_patterns()
self._message_queue: asyncio.Queue = asyncio.Queue()
self._bridge_log_fh = None
self._bridge_log: Optional[Path] = None
self._poll_task: Optional[asyncio.Task] = None
self._http_session: Optional["aiohttp.ClientSession"] = None
self._session_lock_identity: Optional[str] = None
def _whatsapp_require_mention(self) -> bool:
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in ("true", "1", "yes", "on")
return bool(configured)
return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
def _whatsapp_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = os.getenv("WHATSAPP_FREE_RESPONSE_CHATS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _compile_mention_patterns(self):
patterns = self.config.extra.get("mention_patterns")
if patterns is None:
raw = os.getenv("WHATSAPP_MENTION_PATTERNS", "").strip()
if raw:
try:
patterns = json.loads(raw)
except Exception:
patterns = [part.strip() for part in raw.splitlines() if part.strip()]
if not patterns:
patterns = [part.strip() for part in raw.split(",") if part.strip()]
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
logger.warning("[%s] whatsapp mention_patterns must be a list or string; got %s", self.name, type(patterns).__name__)
return []
compiled = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning("[%s] Invalid WhatsApp mention pattern %r: %s", self.name, pattern, exc)
if compiled:
logger.info("[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled))
return compiled
@staticmethod
def _normalize_whatsapp_id(value: Optional[str]) -> str:
if not value:
return ""
normalized = str(value).strip()
if ":" in normalized and "@" in normalized:
normalized = normalized.replace(":", "@", 1)
return normalized
def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]:
bot_ids = set()
for candidate in data.get("botIds") or []:
normalized = self._normalize_whatsapp_id(candidate)
if normalized:
bot_ids.add(normalized)
return bot_ids
def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool:
quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant"))
if not quoted_participant:
return False
return quoted_participant in self._bot_ids_from_message(data)
def _message_mentions_bot(self, data: Dict[str, Any]) -> bool:
bot_ids = self._bot_ids_from_message(data)
if not bot_ids:
return False
mentioned_ids = {
nid
for candidate in (data.get("mentionedIds") or [])
if (nid := self._normalize_whatsapp_id(candidate))
}
if mentioned_ids & bot_ids:
return True
body = str(data.get("body") or "")
lower_body = body.lower()
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0].lower()
if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body):
return True
return False
def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool:
if not self._mention_patterns:
return False
body = str(data.get("body") or "")
return any(pattern.search(body) for pattern in self._mention_patterns)
def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
if not text:
return text
bot_ids = self._bot_ids_from_message(data)
cleaned = text
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0]
if bare_id:
cleaned = re.sub(rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned)
return cleaned.strip() or text
def _should_process_message(self, data: Dict[str, Any]) -> bool:
if not data.get("isGroup"):
return True
chat_id = str(data.get("chatId") or "")
if chat_id in self._whatsapp_free_response_chats():
return True
if not self._whatsapp_require_mention():
return True
body = str(data.get("body") or "").strip()
if body.startswith("/"):
return True
if self._message_is_reply_to_bot(data):
return True
if self._message_mentions_bot(data):
return True
return self._message_matches_mention_patterns(data)
async def connect(self) -> bool:
"""
@@ -687,6 +814,9 @@ class WhatsAppAdapter(BasePlatformAdapter):
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
"""Build a MessageEvent from bridge message data, downloading images to cache."""
try:
if not self._should_process_message(data):
return None
# Determine message type
msg_type = MessageType.TEXT
if data.get("hasMedia"):
@@ -768,6 +898,8 @@ class WhatsAppAdapter(BasePlatformAdapter):
# the message text so the agent can read it inline.
# Cap at 100KB to match Telegram/Discord/Slack behaviour.
body = data.get("body", "")
if data.get("isGroup"):
body = self._clean_bot_mention_text(body, data)
MAX_TEXT_INJECT_BYTES = 100 * 1024
if msg_type == MessageType.DOCUMENT and cached_urls:
for doc_path in cached_urls:
+1282 -313
View File
File diff suppressed because it is too large Load Diff
+72 -54
View File
@@ -254,8 +254,22 @@ def build_session_context_prompt(
if context.source.chat_topic:
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
# User identity (especially useful for WhatsApp where multiple people DM)
if context.source.user_name:
# User identity.
# In shared thread sessions (non-DM with thread_id), multiple users
# contribute to the same conversation. Don't pin a single user name
# in the system prompt — it changes per-turn and would bust the prompt
# cache. Instead, note that this is a multi-user thread; individual
# sender names are prefixed on each user message by the gateway.
_is_shared_thread = (
context.source.chat_type != "dm"
and context.source.thread_id
)
if _is_shared_thread:
lines.append(
"**Session type:** Multi-user thread — messages are prefixed "
"with [sender name]. Multiple users may participate."
)
elif context.source.user_name:
lines.append(f"**User:** {context.source.user_name}")
elif context.source.user_id:
uid = context.source.user_id
@@ -427,7 +441,11 @@ class SessionEntry:
)
def build_session_key(source: SessionSource, group_sessions_per_user: bool = True) -> str:
def build_session_key(
source: SessionSource,
group_sessions_per_user: bool = True,
thread_sessions_per_user: bool = False,
) -> str:
"""Build a deterministic session key from a message source.
This is the single source of truth for session key construction.
@@ -442,7 +460,11 @@ def build_session_key(source: SessionSource, group_sessions_per_user: bool = Tru
- chat_id identifies the parent group/channel.
- user_id/user_id_alt isolates participants within that parent chat when available when
``group_sessions_per_user`` is enabled.
- thread_id differentiates threads within that parent chat.
- thread_id differentiates threads within that parent chat. When
``thread_sessions_per_user`` is False (default), threads are *shared* across all
participants user_id is NOT appended, so every user in the thread
shares a single session. This is the expected UX for threaded
conversations (Telegram forum topics, Discord threads, Slack threads).
- Without participant identifiers, or when isolation is disabled, messages fall back to one
shared session per chat.
- Without identifiers, messages fall back to one session per platform/chat_type.
@@ -464,7 +486,15 @@ def build_session_key(source: SessionSource, group_sessions_per_user: bool = Tru
key_parts.append(source.chat_id)
if source.thread_id:
key_parts.append(source.thread_id)
if group_sessions_per_user and participant_id:
# In threads, default to shared sessions (all participants see the same
# conversation). Per-user isolation only applies when explicitly enabled
# via thread_sessions_per_user, or when there is no thread (regular group).
isolate_user = group_sessions_per_user
if source.thread_id and not thread_sessions_per_user:
isolate_user = False
if isolate_user and participant_id:
key_parts.append(str(participant_id))
return ":".join(key_parts)
@@ -552,6 +582,7 @@ class SessionStore:
return build_session_key(
source,
group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True),
thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False),
)
def _is_session_expired(self, entry: SessionEntry) -> bool:
@@ -738,71 +769,58 @@ class SessionStore:
except Exception as e:
print(f"[gateway] Warning: Failed to create SQLite session: {e}")
# Seed new DM thread sessions with parent DM session history.
# When a bot reply creates a Slack thread and the user responds in it,
# the thread gets a new session (keyed by thread_ts). Without seeding,
# the thread session starts with zero context — the user's original
# question and the bot's answer are invisible. Fix: copy the parent
# DM session's transcript into the new thread session so context carries
# over while still keeping threads isolated from each other.
if (
source.chat_type == "dm"
and source.thread_id
and entry.created_at == entry.updated_at # brand-new session
and not was_auto_reset
):
parent_source = SessionSource(
platform=source.platform,
chat_id=source.chat_id,
chat_type="dm",
user_id=source.user_id,
# no thread_id — this is the parent DM session
)
parent_key = self._generate_session_key(parent_source)
with self._lock:
parent_entry = self._entries.get(parent_key)
if parent_entry and parent_entry.session_id != entry.session_id:
try:
parent_history = self.load_transcript(parent_entry.session_id)
if parent_history:
self.rewrite_transcript(entry.session_id, parent_history)
logger.info(
"[Session] Seeded DM thread session %s with %d messages from parent %s",
entry.session_id, len(parent_history), parent_entry.session_id,
)
except Exception as e:
logger.warning("[Session] Failed to seed thread session: %s", e)
return entry
def update_session(
self,
session_key: str,
input_tokens: int = 0,
output_tokens: int = 0,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
last_prompt_tokens: int = None,
model: str = None,
estimated_cost_usd: Optional[float] = None,
cost_status: Optional[str] = None,
cost_source: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
) -> None:
"""Update a session's metadata after an interaction."""
db_session_id = None
"""Update lightweight session metadata after an interaction."""
with self._lock:
self._ensure_loaded_locked()
if session_key in self._entries:
entry = self._entries[session_key]
entry.updated_at = _now()
# Direct assignment — the gateway receives cumulative totals
# from the cached agent, not per-call deltas.
entry.input_tokens = input_tokens
entry.output_tokens = output_tokens
entry.cache_read_tokens = cache_read_tokens
entry.cache_write_tokens = cache_write_tokens
if last_prompt_tokens is not None:
entry.last_prompt_tokens = last_prompt_tokens
if estimated_cost_usd is not None:
entry.estimated_cost_usd = estimated_cost_usd
if cost_status:
entry.cost_status = cost_status
entry.total_tokens = (
entry.input_tokens
+ entry.output_tokens
+ entry.cache_read_tokens
+ entry.cache_write_tokens
)
self._save()
db_session_id = entry.session_id
if self._db and db_session_id:
try:
self._db.set_token_counts(
db_session_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
estimated_cost_usd=estimated_cost_usd,
cost_status=cost_status,
cost_source=cost_source,
billing_provider=provider,
billing_base_url=base_url,
model=model,
absolute=True,
)
except Exception as e:
logger.debug("Session DB operation failed: %s", e)
def reset_session(self, session_key: str) -> Optional[SessionEntry]:
"""Force reset a session, creating a new session ID."""
+36 -4
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio
import logging
import queue
import re
import time
from dataclasses import dataclass
from typing import Any, Optional
@@ -156,8 +157,39 @@ class GatewayStreamConsumer:
except Exception as e:
logger.error("Stream consumer error: %s", e)
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
# Matches the simple cleanup regex used by the non-streaming path in
# gateway/platforms/base.py for post-processing.
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
@staticmethod
def _clean_for_display(text: str) -> str:
"""Strip MEDIA: directives and internal markers from text before display.
The streaming path delivers raw text chunks that may include
``MEDIA:<path>`` tags and ``[[audio_as_voice]]`` directives meant for
the platform adapter's post-processing. The actual media files are
delivered separately via ``_deliver_media_from_response()`` after the
stream finishes we just need to hide the raw directives from the
user.
"""
if "MEDIA:" not in text and "[[audio_as_voice]]" not in text:
return text
cleaned = text.replace("[[audio_as_voice]]", "")
cleaned = GatewayStreamConsumer._MEDIA_RE.sub("", cleaned)
# Collapse excessive blank lines left behind by removed tags
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
# Strip trailing whitespace/newlines but preserve leading content
return cleaned.rstrip()
async def _send_or_edit(self, text: str) -> None:
"""Send or edit the streaming message."""
# Strip MEDIA: directives so they don't appear as visible text.
# Media files are delivered as native attachments after the stream
# finishes (via _deliver_media_from_response in gateway/run.py).
text = self._clean_for_display(text)
if not text.strip():
return
try:
if self._message_id is not None:
if self._edit_supported:
@@ -174,12 +206,12 @@ class GatewayStreamConsumer:
self._already_sent = True
self._last_sent_text = text
else:
# Edit not supported by this adapter — stop streaming,
# let the normal send path handle the final response.
# Without this guard, adapters like Signal/Email would
# flood the chat with a new message every edit_interval.
# If an edit fails mid-stream (especially Telegram flood control),
# stop progressive edits and let the normal final send path deliver
# the complete answer instead of leaving the user with a partial.
logger.debug("Edit failed, disabling streaming for this adapter")
self._edit_supported = False
self._already_sent = False
else:
# Editing not supported — skip intermediate updates.
# The final response will be sent by the normal path.
+2 -2
View File
@@ -11,5 +11,5 @@ Provides subcommands for:
- hermes cron - Manage cron jobs
"""
__version__ = "0.6.0"
__release_date__ = "2026.3.30"
__version__ = "0.7.0"
__release_date__ = "2026.4.3"
+301 -48
View File
@@ -69,6 +69,7 @@ DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@@ -125,6 +126,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
base_url_env_var="COPILOT_ACP_BASE_URL",
),
"gemini": ProviderConfig(
id="gemini",
name="Google AI Studio",
auth_type="api_key",
inference_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
base_url_env_var="GEMINI_BASE_URL",
),
"zai": ProviderConfig(
id="zai",
name="Z.AI / GLM",
@@ -200,6 +209,10 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
id="opencode-go",
name="OpenCode Go",
auth_type="api_key",
# OpenCode Go mixes API surfaces by model:
# - GLM / Kimi use OpenAI-compatible chat completions under /v1
# - MiniMax models use Anthropic Messages under /v1/messages
# Keep the provider base at /v1 and select api_mode per-model.
inference_base_url="https://opencode.ai/zen/go/v1",
api_key_env_vars=("OPENCODE_GO_API_KEY",),
base_url_env_var="OPENCODE_GO_BASE_URL",
@@ -707,6 +720,32 @@ def deactivate_provider() -> None:
# Provider Resolution — picks which provider to use
# =============================================================================
def _get_config_hint_for_unknown_provider(provider_name: str) -> str:
"""Return a helpful hint string when provider resolution fails.
Checks for common config.yaml mistakes (malformed custom_providers, etc.)
and returns a human-readable diagnostic, or empty string if nothing found.
"""
try:
from hermes_cli.config import validate_config_structure
issues = validate_config_structure()
if not issues:
return ""
lines = ["Config issue detected — run 'hermes doctor' for full diagnostics:"]
for ci in issues:
prefix = "ERROR" if ci.severity == "error" else "WARNING"
lines.append(f" [{prefix}] {ci.message}")
# Show first line of hint
first_hint = ci.hint.splitlines()[0] if ci.hint else ""
if first_hint:
lines.append(f"{first_hint}")
return "\n".join(lines)
except Exception:
return ""
def resolve_provider(
requested: Optional[str] = None,
*,
@@ -728,6 +767,7 @@ def resolve_provider(
# Normalize provider aliases
_PROVIDER_ALIASES = {
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
"kimi": "kimi-coding", "moonshot": "kimi-coding",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"claude": "anthropic", "claude-code": "anthropic",
@@ -753,10 +793,14 @@ def resolve_provider(
if normalized in PROVIDER_REGISTRY:
return normalized
if normalized != "auto":
raise AuthError(
f"Unknown provider '{normalized}'.",
code="invalid_provider",
)
# Check for common config.yaml issues that cause this error
_config_hint = _get_config_hint_for_unknown_provider(normalized)
msg = f"Unknown provider '{normalized}'."
if _config_hint:
msg += f"\n\n{_config_hint}"
else:
msg += " Check 'hermes model' for available providers, or run 'hermes doctor' to diagnose config issues."
raise AuthError(msg, code="invalid_provider")
# Explicit one-off CLI creds always mean openrouter/custom
if explicit_api_key or explicit_base_url:
@@ -892,7 +936,7 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
state = _load_provider_state(auth_store, "openai-codex")
if not state:
raise AuthError(
"No Codex credentials stored. Run `hermes login` to authenticate.",
"No Codex credentials stored. Run `hermes auth` to authenticate.",
provider="openai-codex",
code="codex_auth_missing",
relogin_required=True,
@@ -900,7 +944,7 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
tokens = state.get("tokens")
if not isinstance(tokens, dict):
raise AuthError(
"Codex auth state is missing tokens. Run `hermes login` to re-authenticate.",
"Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.",
provider="openai-codex",
code="codex_auth_invalid_shape",
relogin_required=True,
@@ -909,14 +953,14 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
refresh_token = tokens.get("refresh_token")
if not isinstance(access_token, str) or not access_token.strip():
raise AuthError(
"Codex auth is missing access_token. Run `hermes login` to re-authenticate.",
"Codex auth is missing access_token. Run `hermes auth` to re-authenticate.",
provider="openai-codex",
code="codex_auth_missing_access_token",
relogin_required=True,
)
if not isinstance(refresh_token, str) or not refresh_token.strip():
raise AuthError(
"Codex auth is missing refresh_token. Run `hermes login` to re-authenticate.",
"Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.",
provider="openai-codex",
code="codex_auth_missing_refresh_token",
relogin_required=True,
@@ -951,7 +995,7 @@ def refresh_codex_oauth_pure(
del access_token # Access token is only used by callers to decide whether to refresh.
if not isinstance(refresh_token, str) or not refresh_token.strip():
raise AuthError(
"Codex auth is missing refresh_token. Run `hermes login` to re-authenticate.",
"Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.",
provider="openai-codex",
code="codex_auth_missing_refresh_token",
relogin_required=True,
@@ -986,6 +1030,14 @@ def refresh_codex_oauth_pure(
pass
if code in {"invalid_grant", "invalid_token", "invalid_request"}:
relogin_required = True
if code == "refresh_token_reused":
message = (
"Codex refresh token was already consumed by another client "
"(e.g. Codex CLI or VS Code extension). "
"Run `codex` in your terminal to generate fresh tokens, "
"then run `hermes auth` to re-authenticate."
)
relogin_required = True
raise AuthError(
message,
provider="openai-codex",
@@ -1047,7 +1099,8 @@ def _refresh_codex_auth_tokens(
def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
"""Try to read tokens from ~/.codex/auth.json (Codex CLI shared file).
Returns tokens dict if valid, None otherwise. Does NOT write to the shared file.
Returns tokens dict if valid and not expired, None otherwise.
Does NOT write to the shared file.
"""
codex_home = os.getenv("CODEX_HOME", "").strip()
if not codex_home:
@@ -1060,7 +1113,17 @@ def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
tokens = payload.get("tokens")
if not isinstance(tokens, dict):
return None
if not tokens.get("access_token") or not tokens.get("refresh_token"):
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not access_token or not refresh_token:
return None
# Reject expired tokens — importing stale tokens from ~/.codex/
# that can't be refreshed leaves the user stuck with "Login successful!"
# but no working credentials.
if _codex_access_token_is_expiring(access_token, 0):
logger.debug(
"Codex CLI tokens at %s are expired — skipping import.", auth_path,
)
return None
return dict(tokens)
except Exception:
@@ -1088,7 +1151,7 @@ def resolve_codex_runtime_credentials(
logger.info("Migrating Codex credentials from ~/.codex/ to Hermes auth store")
print("⚠️ Migrating Codex credentials to Hermes's own auth store.")
print(" This avoids conflicts with Codex CLI and VS Code.")
print(" Run `hermes login` to create a fully independent session.\n")
print(" Run `hermes auth` to create a fully independent session.\n")
_save_codex_tokens(cli_tokens)
data = _read_codex_tokens()
else:
@@ -1377,6 +1440,89 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
def resolve_nous_access_token(
*,
timeout_seconds: float = 15.0,
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> str:
"""Resolve a refresh-aware Nous Portal access token for managed tool gateways."""
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
if not state:
raise AuthError(
"Hermes is not logged into Nous Portal.",
provider="nous",
relogin_required=True,
)
portal_base_url = (
_optional_base_url(state.get("portal_base_url"))
or os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL")
or DEFAULT_NOUS_PORTAL_URL
).rstrip("/")
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
if not isinstance(access_token, str) or not access_token:
raise AuthError(
"No access token found for Nous Portal login.",
provider="nous",
relogin_required=True,
)
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
return access_token
if not isinstance(refresh_token, str) or not refresh_token:
raise AuthError(
"Session expired and no refresh token is available.",
provider="nous",
relogin_required=True,
)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
with httpx.Client(
timeout=timeout,
headers={"Accept": "application/json"},
verify=verify,
) as client:
refreshed = _refresh_access_token(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
refresh_token=refresh_token,
)
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
state["obtained_at"] = now.isoformat()
state["expires_in"] = access_ttl
state["expires_at"] = datetime.fromtimestamp(
now.timestamp() + access_ttl,
tz=timezone.utc,
).isoformat()
state["portal_base_url"] = portal_base_url
state["client_id"] = client_id
state["tls"] = {
"insecure": verify is False,
"ca_bundle": verify if isinstance(verify, str) else None,
}
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
return state["access_token"]
def refresh_nous_oauth_pure(
access_token: str,
refresh_token: str,
@@ -1769,7 +1915,36 @@ def get_nous_auth_status() -> Dict[str, Any]:
def get_codex_auth_status() -> Dict[str, Any]:
"""Status snapshot for Codex auth."""
"""Status snapshot for Codex auth.
Checks the credential pool first (where `hermes auth` stores credentials),
then falls back to the legacy provider state.
"""
# Check credential pool first — this is where `hermes auth` and
# `hermes model` store device_code tokens.
try:
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
api_key = (
getattr(entry, "runtime_api_key", None)
or getattr(entry, "access_token", "")
)
if api_key and not _codex_access_token_is_expiring(api_key, 0):
return {
"logged_in": True,
"auth_store": str(_auth_file_path()),
"last_refresh": getattr(entry, "last_refresh", None),
"auth_mode": "chatgpt",
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
"api_key": api_key,
}
except Exception:
pass
# Fall back to legacy provider state
try:
creds = resolve_codex_runtime_credentials()
return {
@@ -1778,6 +1953,7 @@ def get_codex_auth_status() -> Dict[str, Any]:
"last_refresh": creds.get("last_refresh"),
"auth_mode": creds.get("auth_mode"),
"source": creds.get("source"),
"api_key": creds.get("api_key"),
}
except AuthError as exc:
return {
@@ -1961,7 +2137,7 @@ def detect_external_credentials() -> List[Dict[str, Any]]:
found.append({
"provider": "openai-codex",
"path": str(codex_path),
"label": f"Codex CLI credentials found ({codex_path}) — run `hermes login` to create a separate session",
"label": f"Codex CLI credentials found ({codex_path}) — run `hermes auth` to create a separate session",
})
return found
@@ -2056,8 +2232,18 @@ def _reset_config_provider() -> Path:
return config_path
def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None."""
def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
pricing: Optional[Dict[str, Dict[str, str]]] = None,
) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
If *pricing* is provided (``{model_id: {prompt, completion}}``), a compact
price indicator is shown next to each model in aligned columns.
"""
from hermes_cli.models import _format_price_per_mtok
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
@@ -2066,15 +2252,61 @@ def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Op
if mid not in ordered:
ordered.append(mid)
# Build display labels with marker on current
# Column-aligned labels when pricing is available
has_pricing = bool(pricing and any(pricing.get(m) for m in ordered))
name_col = max((len(m) for m in ordered), default=0) + 2 if has_pricing else 0
# Pre-compute formatted prices and dynamic column widths
_price_cache: dict[str, tuple[str, str, str]] = {}
price_col = 3 # minimum width
cache_col = 0 # only set if any model has cache pricing
has_cache = False
if has_pricing:
for mid in ordered:
p = pricing.get(mid) # type: ignore[union-attr]
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
out = _format_price_per_mtok(p.get("completion", ""))
cache_read = p.get("input_cache_read", "")
cache = _format_price_per_mtok(cache_read) if cache_read else ""
if cache:
has_cache = True
else:
inp, out, cache = "", "", ""
_price_cache[mid] = (inp, out, cache)
price_col = max(price_col, len(inp), len(out))
cache_col = max(cache_col, len(cache))
if has_cache:
cache_col = max(cache_col, 5) # minimum: "Cache" header
def _label(mid):
if has_pricing:
inp, out, cache = _price_cache.get(mid, ("", "", ""))
price_part = f" {inp:>{price_col}} {out:>{price_col}}"
if has_cache:
price_part += f" {cache:>{cache_col}}"
base = f"{mid:<{name_col}}{price_part}"
else:
base = mid
if mid == current_model:
return f"{mid} ← currently in use"
return mid
base += " ← currently in use"
return base
# Default cursor on the current model (index 0 if it was reordered to top)
default_idx = 0
# Build a pricing header hint for the menu title
menu_title = "Select default model:"
if has_pricing:
# Align the header with the model column.
# Each choice is " {label}" (2 spaces) and simple_term_menu prepends
# a 3-char cursor region ("-> " or " "), so content starts at col 5.
pad = " " * 5
header = f"\n{pad}{'':>{name_col}} {'In':>{price_col}} {'Out':>{price_col}}"
if has_cache:
header += f" {'Cache':>{cache_col}}"
menu_title += header + " /Mtok"
# Try arrow-key menu first, fall back to number input
try:
from simple_term_menu import TerminalMenu
@@ -2089,7 +2321,7 @@ def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Op
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
title="Select default model:",
title=menu_title,
)
idx = menu.show()
if idx is None:
@@ -2105,12 +2337,13 @@ def _prompt_model_selection(model_ids: List[str], current_model: str = "") -> Op
pass
# Fallback: numbered list
print("Select default model:")
print(menu_title)
num_width = len(str(len(ordered) + 2))
for i, mid in enumerate(ordered, 1):
print(f" {i}. {_label(mid)}")
print(f" {i:>{num_width}}. {_label(mid)}")
n = len(ordered)
print(f" {n + 1}. Enter custom model name")
print(f" {n + 2}. Skip (keep current)")
print(f" {n + 1:>{num_width}}. Enter custom model name")
print(f" {n + 2:>{num_width}}. Skip (keep current)")
print()
while True:
@@ -2153,8 +2386,8 @@ def _save_model_choice(model_id: str) -> None:
def login_command(args) -> None:
"""Deprecated: use 'hermes model' or 'hermes setup' instead."""
print("The 'hermes login' command has been removed.")
print("Use 'hermes model' to select a provider and model,")
print("or 'hermes setup' for full interactive setup.")
print("Use 'hermes auth' to manage credentials,")
print("'hermes model' to select a provider, or 'hermes setup' for full setup.")
raise SystemExit(0)
@@ -2164,17 +2397,25 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
# Check for existing Hermes-owned credentials
try:
existing = resolve_codex_runtime_credentials()
print("Existing Codex credentials found in Hermes auth store.")
try:
reuse = input("Use existing credentials? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
reuse = "y"
if reuse in ("", "y", "yes"):
config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL))
print()
print("Login successful!")
print(f" Config updated: {config_path} (model.provider=openai-codex)")
return
# Verify the resolved token is actually usable (not expired).
# resolve_codex_runtime_credentials attempts refresh, so if we get
# here the token should be valid — but double-check before telling
# the user "Login successful!".
_resolved_key = existing.get("api_key", "")
if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60):
print("Existing Codex credentials found in Hermes auth store.")
try:
reuse = input("Use existing credentials? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
reuse = "y"
if reuse in ("", "y", "yes"):
config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL))
print()
print("Login successful!")
print(f" Config updated: {config_path} (model.provider=openai-codex)")
return
else:
print("Existing Codex credentials are expired. Starting fresh login...")
except AuthError:
pass
@@ -2469,13 +2710,26 @@ def _nous_device_code_login(
"agent_key_reused": None,
"agent_key_obtained_at": None,
}
return refresh_nous_oauth_from_state(
auth_state,
min_key_ttl_seconds=min_key_ttl_seconds,
timeout_seconds=timeout_seconds,
force_refresh=False,
force_mint=True,
)
try:
return refresh_nous_oauth_from_state(
auth_state,
min_key_ttl_seconds=min_key_ttl_seconds,
timeout_seconds=timeout_seconds,
force_refresh=False,
force_mint=True,
)
except AuthError as exc:
if exc.code == "subscription_required":
portal_url = auth_state.get(
"portal_base_url", DEFAULT_NOUS_PORTAL_URL
).rstrip("/")
print()
print("Your Nous Portal account does not have an active subscription.")
print(f" Subscribe here: {portal_url}/billing")
print()
print("After subscribing, run `hermes model` again to finish setup.")
raise SystemExit(1)
raise
def _login_nous(args, pconfig: ProviderConfig) -> None:
@@ -2490,8 +2744,8 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
try:
auth_state = _nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None) or pconfig.portal_base_url,
inference_base_url=getattr(args, "inference_url", None) or pconfig.inference_base_url,
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None) or pconfig.client_id,
scope=getattr(args, "scope", None) or pconfig.scope,
open_browser=not getattr(args, "no_browser", False),
@@ -2500,6 +2754,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
ca_bundle=ca_bundle,
min_key_ttl_seconds=5 * 60,
)
inference_base_url = auth_state["inference_base_url"]
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
@@ -2523,8 +2778,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
code="invalid_token",
)
# Use curated model list (same as OpenRouter defaults) instead
# of the full /models dump which returns hundreds of models.
from hermes_cli.models import _PROVIDER_MODELS
model_ids = _PROVIDER_MODELS.get("nous", [])
+68 -19
View File
@@ -20,12 +20,12 @@ from agent.credential_pool import (
STRATEGY_LEAST_USED,
SUPPORTED_POOL_STRATEGIES,
PooledCredential,
_exhausted_until,
_normalize_custom_pool_name,
get_pool_strategy,
label_from_token,
list_custom_pool_providers,
load_pool,
_exhausted_ttl,
)
import hermes_cli.auth as auth_mod
from hermes_cli.auth import PROVIDER_REGISTRY
@@ -113,21 +113,27 @@ def _display_source(source: str) -> str:
def _format_exhausted_status(entry) -> str:
if entry.last_status != STATUS_EXHAUSTED:
return ""
reason = getattr(entry, "last_error_reason", None)
reason_text = f" {reason}" if isinstance(reason, str) and reason.strip() else ""
code = f" ({entry.last_error_code})" if entry.last_error_code else ""
if not entry.last_status_at:
return f" exhausted{code}"
remaining = max(0, int(math.ceil((entry.last_status_at + _exhausted_ttl(entry.last_error_code)) - time.time())))
exhausted_until = _exhausted_until(entry)
if exhausted_until is None:
return f" exhausted{reason_text}{code}"
remaining = max(0, int(math.ceil(exhausted_until - time.time())))
if remaining <= 0:
return f" exhausted{code} (ready to retry)"
return f" exhausted{reason_text}{code} (ready to retry)"
minutes, seconds = divmod(remaining, 60)
hours, minutes = divmod(minutes, 60)
if hours:
days, hours = divmod(hours, 24)
if days:
wait = f"{days}d {hours}h"
elif hours:
wait = f"{hours}h {minutes}m"
elif minutes:
wait = f"{minutes}m {seconds}s"
else:
wait = f"{seconds}s"
return f" exhausted{code} ({wait} left)"
return f" exhausted{reason_text}{code} ({wait} left)"
def auth_add_command(args) -> None:
@@ -277,13 +283,54 @@ def auth_list_command(args) -> None:
def auth_remove_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", ""))
index = int(getattr(args, "index"))
target = getattr(args, "target", None)
if target is None:
target = getattr(args, "index", None)
pool = load_pool(provider)
index, matched, error = pool.resolve_target(target)
if matched is None or index is None:
raise SystemExit(f"{error} Provider: {provider}.")
removed = pool.remove_index(index)
if removed is None:
raise SystemExit(f"No credential #{index} for provider {provider}.")
raise SystemExit(f'No credential matching "{target}" for provider {provider}.')
print(f"Removed {provider} credential #{index} ({removed.label})")
# If this was an env-seeded credential, also clear the env var from .env
# so it doesn't get re-seeded on the next load_pool() call.
if removed.source.startswith("env:"):
env_var = removed.source[len("env:"):]
if env_var:
from hermes_cli.config import remove_env_value
cleared = remove_env_value(env_var)
if cleared:
print(f"Cleared {env_var} from .env")
# If this was a singleton-seeded credential (OAuth device_code, hermes_pkce),
# clear the underlying auth store / credential file so it doesn't get
# re-seeded on the next load_pool() call.
elif removed.source == "device_code" and provider in ("openai-codex", "nous"):
from hermes_cli.auth import (
_load_auth_store, _save_auth_store, _auth_store_lock,
)
with _auth_store_lock():
auth_store = _load_auth_store()
providers_dict = auth_store.get("providers")
if isinstance(providers_dict, dict) and provider in providers_dict:
del providers_dict[provider]
_save_auth_store(auth_store)
print(f"Cleared {provider} OAuth tokens from auth store")
elif removed.source == "hermes_pkce" and provider == "anthropic":
from hermes_constants import get_hermes_home
oauth_file = get_hermes_home() / ".anthropic_oauth.json"
if oauth_file.exists():
oauth_file.unlink()
print("Cleared Hermes Anthropic OAuth credentials")
elif removed.source == "claude_code" and provider == "anthropic":
print("Note: Claude Code credentials live in ~/.claude/.credentials.json")
print(" Remove them manually if you want to deauthorize Claude Code.")
def auth_reset_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", ""))
@@ -369,8 +416,16 @@ def _interactive_add() -> None:
else:
auth_type = "api_key"
label = None
try:
typed_label = input("Label / account name (optional): ").strip()
except (EOFError, KeyboardInterrupt):
return
if typed_label:
label = typed_label
auth_add_command(SimpleNamespace(
provider=provider, auth_type=auth_type, label=None, api_key=None,
provider=provider, auth_type=auth_type, label=label, api_key=None,
portal_url=None, inference_url=None, client_id=None, scope=None,
no_browser=False, timeout=None, insecure=False, ca_bundle=None,
))
@@ -386,22 +441,16 @@ def _interactive_remove() -> None:
# Show entries with indices
for i, e in enumerate(pool.entries(), 1):
exhausted = _format_exhausted_status(e)
print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted}")
print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted} [id:{e.id}]")
try:
raw = input("Remove # (or blank to cancel): ").strip()
raw = input("Remove #, id, or label (blank to cancel): ").strip()
except (EOFError, KeyboardInterrupt):
return
if not raw:
return
try:
index = int(raw)
except ValueError:
print("Invalid number.")
return
auth_remove_command(SimpleNamespace(provider=provider, index=index))
auth_remove_command(SimpleNamespace(provider=provider, target=raw))
def _interactive_reset() -> None:
+224 -46
View File
@@ -57,6 +57,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
CommandDef("title", "Set a title for the current session", "Session",
args_hint="[name]"),
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
aliases=("fork",), args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
@@ -82,6 +84,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
# Configuration
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"),
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("prompt", "View/set custom system prompt", "Configuration",
@@ -363,21 +366,46 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
continue
tg_name = cmd.name.replace("-", "_")
result.append((tg_name, cmd.description))
tg_name = _sanitize_telegram_name(cmd.name)
if tg_name:
result.append((tg_name, cmd.description))
return result
_TG_NAME_LIMIT = 32
_CMD_NAME_LIMIT = 32
"""Max command name length shared by Telegram and Discord."""
# Backward-compat alias — tests and external code may reference the old name.
_TG_NAME_LIMIT = _CMD_NAME_LIMIT
# Telegram Bot API allows only lowercase a-z, 0-9, and underscores in
# command names. This regex strips everything else after initial conversion.
_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]")
_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}")
def _clamp_telegram_names(
def _sanitize_telegram_name(raw: str) -> str:
"""Convert a command/skill/plugin name to a valid Telegram command name.
Telegram requires: 1-32 chars, lowercase a-z, digits 0-9, underscores only.
Steps: lowercase replace hyphens with underscores strip all other
invalid characters collapse consecutive underscores strip leading/
trailing underscores.
"""
name = raw.lower().replace("-", "_")
name = _TG_INVALID_CHARS.sub("", name)
name = _TG_MULTI_UNDERSCORE.sub("_", name)
return name.strip("_")
def _clamp_command_names(
entries: list[tuple[str, str]],
reserved: set[str],
) -> list[tuple[str, str]]:
"""Enforce Telegram's 32-char command name limit with collision avoidance.
"""Enforce 32-char command name limit with collision avoidance.
Names exceeding 32 chars are truncated. If truncation creates a duplicate
Both Telegram and Discord cap slash command names at 32 characters.
Names exceeding the limit are truncated. If truncation creates a duplicate
(against *reserved* names or earlier entries in the same batch), the name is
shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.
If all 10 digit slots are taken the entry is silently dropped.
@@ -385,10 +413,10 @@ def _clamp_telegram_names(
used: set[str] = set(reserved)
result: list[tuple[str, str]] = []
for name, desc in entries:
if len(name) > _TG_NAME_LIMIT:
candidate = name[:_TG_NAME_LIMIT]
if len(name) > _CMD_NAME_LIMIT:
candidate = name[:_CMD_NAME_LIMIT]
if candidate in used:
prefix = name[:_TG_NAME_LIMIT - 1]
prefix = name[:_CMD_NAME_LIMIT - 1]
for digit in range(10):
candidate = f"{prefix}{digit}"
if candidate not in used:
@@ -404,48 +432,83 @@ def _clamp_telegram_names(
return result
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
"""Return Telegram menu commands capped to the Bot API limit.
# Backward-compat alias.
_clamp_telegram_names = _clamp_command_names
Priority order (higher priority = never bumped by overflow):
1. Core CommandDef commands (always included)
2. Plugin slash commands (take precedence over skills)
3. Built-in skill commands (fill remaining slots, alphabetical)
Skills are the only tier that gets trimmed when the cap is hit.
User-installed hub skills are excluded accessible via /skills.
# ---------------------------------------------------------------------------
# Shared skill/plugin collection for gateway platforms
# ---------------------------------------------------------------------------
def _collect_gateway_skill_entries(
platform: str,
max_slots: int,
reserved_names: set[str],
desc_limit: int = 100,
sanitize_name: "Callable[[str], str] | None" = None,
) -> tuple[list[tuple[str, str, str]], int]:
"""Collect plugin + skill entries for a gateway platform.
Priority order:
1. Plugin slash commands (take precedence over skills)
2. Built-in skill commands (fill remaining slots, alphabetical)
Only skills are trimmed when the cap is reached.
Hub-installed skills are excluded. Per-platform disabled skills are
excluded.
Args:
platform: Platform identifier for per-platform skill filtering
(``"telegram"``, ``"discord"``, etc.).
max_slots: Maximum number of entries to return (remaining slots after
built-in/core commands).
reserved_names: Names already taken by built-in commands. Mutated
in-place as new names are added.
desc_limit: Max description length (40 for Telegram, 100 for Discord).
sanitize_name: Optional name transform applied before clamping, e.g.
:func:`_sanitize_telegram_name` for Telegram. May return an
empty string to signal "skip this entry".
Returns:
(menu_commands, hidden_count) where hidden_count is the number of
skill commands omitted due to the cap.
``(entries, hidden_count)`` where *entries* is a list of
``(name, description, cmd_key)`` triples and *hidden_count* is the
number of skill entries dropped due to the cap. ``cmd_key`` is the
original ``/skill-name`` key from :func:`get_skill_commands`.
"""
core_commands = list(telegram_bot_commands())
# Reserve core names so plugin/skill truncation can't collide with them
reserved_names = {n for n, _ in core_commands}
all_commands = list(core_commands)
all_entries: list[tuple[str, str, str]] = []
# Plugin slash commands get priority over skills
plugin_entries: list[tuple[str, str]] = []
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
plugin_pairs: list[tuple[str, str]] = []
try:
from hermes_cli.plugins import get_plugin_manager
pm = get_plugin_manager()
plugin_cmds = getattr(pm, "_plugin_commands", {})
for cmd_name in sorted(plugin_cmds):
tg_name = cmd_name.replace("-", "_")
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
if not name:
continue
desc = "Plugin command"
if len(desc) > 40:
desc = desc[:37] + "..."
plugin_entries.append((tg_name, desc))
if len(desc) > desc_limit:
desc = desc[:desc_limit - 3] + "..."
plugin_pairs.append((name, desc))
except Exception:
pass
# Clamp plugin names to 32 chars with collision avoidance
plugin_entries = _clamp_telegram_names(plugin_entries, reserved_names)
reserved_names.update(n for n, _ in plugin_entries)
all_commands.extend(plugin_entries)
plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names)
reserved_names.update(n for n, _ in plugin_pairs)
# Plugins have no cmd_key — use empty string as placeholder
for n, d in plugin_pairs:
all_entries.append((n, d, ""))
# Remaining slots go to built-in skill commands (not hub-installed).
skill_entries: list[tuple[str, str]] = []
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
_platform_disabled: set[str] = set()
try:
from agent.skill_utils import get_disabled_skill_names
_platform_disabled = get_disabled_skill_names(platform=platform)
except Exception:
pass
skill_triples: list[tuple[str, str, str]] = []
try:
from agent.skill_commands import get_skill_commands
from tools.skills_tool import SKILLS_DIR
@@ -459,26 +522,103 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str
continue
if skill_path.startswith(_hub_dir):
continue
name = cmd_key.lstrip("/").replace("-", "_")
skill_name = info.get("name", "")
if skill_name in _platform_disabled:
continue
raw_name = cmd_key.lstrip("/")
name = sanitize_name(raw_name) if sanitize_name else raw_name
if not name:
continue
desc = info.get("description", "")
# Keep descriptions short — setMyCommands has an undocumented
# total payload limit. 40 chars fits 100 commands safely.
if len(desc) > 40:
desc = desc[:37] + "..."
skill_entries.append((name, desc))
if len(desc) > desc_limit:
desc = desc[:desc_limit - 3] + "..."
skill_triples.append((name, desc, cmd_key))
except Exception:
pass
# Clamp skill names to 32 chars with collision avoidance
skill_entries = _clamp_telegram_names(skill_entries, reserved_names)
# Clamp names; _clamp_command_names works on (name, desc) pairs so we
# need to zip/unzip.
skill_pairs = [(n, d) for n, d, _ in skill_triples]
key_by_pair = {(n, d): k for n, d, k in skill_triples}
skill_pairs = _clamp_command_names(skill_pairs, reserved_names)
# Skills fill remaining slots — only tier that gets trimmed
remaining = max(0, max_slots - len(all_entries))
hidden_count = max(0, len(skill_pairs) - remaining)
for n, d in skill_pairs[:remaining]:
all_entries.append((n, d, key_by_pair.get((n, d), "")))
return all_entries[:max_slots], hidden_count
# ---------------------------------------------------------------------------
# Platform-specific wrappers
# ---------------------------------------------------------------------------
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
"""Return Telegram menu commands capped to the Bot API limit.
Priority order (higher priority = never bumped by overflow):
1. Core CommandDef commands (always included)
2. Plugin slash commands (take precedence over skills)
3. Built-in skill commands (fill remaining slots, alphabetical)
Skills are the only tier that gets trimmed when the cap is hit.
User-installed hub skills are excluded accessible via /skills.
Skills disabled for the ``"telegram"`` platform (via ``hermes skills
config``) are excluded from the menu entirely.
Returns:
(menu_commands, hidden_count) where hidden_count is the number of
skill commands omitted due to the cap.
"""
core_commands = list(telegram_bot_commands())
reserved_names = {n for n, _ in core_commands}
all_commands = list(core_commands)
# Skills fill remaining slots — they're the only tier that gets trimmed
remaining_slots = max(0, max_commands - len(all_commands))
hidden_count = max(0, len(skill_entries) - remaining_slots)
all_commands.extend(skill_entries[:remaining_slots])
entries, hidden_count = _collect_gateway_skill_entries(
platform="telegram",
max_slots=remaining_slots,
reserved_names=reserved_names,
desc_limit=40,
sanitize_name=_sanitize_telegram_name,
)
# Drop the cmd_key — Telegram only needs (name, desc) pairs.
all_commands.extend((n, d) for n, d, _k in entries)
return all_commands[:max_commands], hidden_count
def discord_skill_commands(
max_slots: int,
reserved_names: set[str],
) -> tuple[list[tuple[str, str, str]], int]:
"""Return skill entries for Discord slash command registration.
Same priority and filtering logic as :func:`telegram_menu_commands`
(plugins > skills, hub excluded, per-platform disabled excluded), but
adapted for Discord's constraints:
- Hyphens are allowed in names (no ``-`` ``_`` sanitization)
- Descriptions capped at 100 chars (Discord's per-field max)
Args:
max_slots: Available command slots (100 minus existing built-in count).
reserved_names: Names of already-registered built-in commands.
Returns:
``(entries, hidden_count)`` where *entries* is a list of
``(discord_name, description, cmd_key)`` triples. ``cmd_key`` is
the original ``/skill-name`` key needed for the slash handler callback.
"""
return _collect_gateway_skill_entries(
platform="discord",
max_slots=max_slots,
reserved_names=set(reserved_names), # copy — don't mutate caller's set
desc_limit=100,
)
def slack_subcommand_map() -> dict[str, str]:
"""Return subcommand -> /command mapping for Slack /hermes handler.
@@ -725,6 +865,39 @@ class SlashCommandCompleter(Completer):
)
count += 1
def _model_completions(self, sub_text: str, sub_lower: str):
"""Yield completions for /model from config aliases + built-in aliases."""
seen = set()
# Config-based direct aliases (preferred — include provider info)
try:
from hermes_cli.model_switch import (
_ensure_direct_aliases, DIRECT_ALIASES, MODEL_ALIASES,
)
_ensure_direct_aliases()
for name, da in DIRECT_ALIASES.items():
if name.startswith(sub_lower) and name != sub_lower:
seen.add(name)
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=f"{da.model} ({da.provider})",
)
# Built-in catalog aliases not already covered
for name in sorted(MODEL_ALIASES.keys()):
if name in seen:
continue
if name.startswith(sub_lower) and name != sub_lower:
identity = MODEL_ALIASES[name]
yield Completion(
name,
start_position=-len(sub_text),
display=name,
display_meta=f"{identity.vendor}/{identity.family}",
)
except Exception:
pass
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
@@ -746,6 +919,11 @@ class SlashCommandCompleter(Completer):
sub_text = parts[1] if len(parts) > 1 else ""
sub_lower = sub_text.lower()
# Dynamic model alias completions for /model
if " " not in sub_text and base_cmd == "/model":
yield from self._model_completions(sub_text, sub_lower)
return
# Static subcommand completions
if " " not in sub_text and base_cmd in SUBCOMMANDS:
for sub in SUBCOMMANDS[base_cmd]:
+528 -10
View File
@@ -19,9 +19,12 @@ import stat
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
@@ -39,9 +42,9 @@ _EXTRA_ENV_KEYS = frozenset({
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
})
import yaml
from hermes_cli.colors import Colors, color
@@ -197,11 +200,17 @@ def ensure_hermes_home():
DEFAULT_CONFIG = {
"model": "",
"providers": {},
"fallback_providers": [],
"credential_pool_strategies": {},
"toolsets": ["hermes-cli"],
"agent": {
"max_turns": 90,
# Inactivity timeout for gateway agent execution (seconds).
# The agent can run indefinitely as long as it's actively calling
# tools or receiving API responses. Only fires when the agent has
# been completely idle for this duration. 0 = unlimited.
"gateway_timeout": 1800,
# Tool-use enforcement: injects system prompt guidance that tells the
# model to actually call tools instead of describing intended actions.
# Values: "auto" (default — applies to gpt/codex models), true/false
@@ -212,6 +221,7 @@ DEFAULT_CONFIG = {
"terminal": {
"backend": "local",
"modal_mode": "auto",
"cwd": ".", # Use current directory
"timeout": 180,
# Environment variables to pass through to sandboxed execution
@@ -220,6 +230,12 @@ DEFAULT_CONFIG = {
"env_passthrough": [],
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
# Explicit environment variables to set inside Docker containers.
# Unlike docker_forward_env (which reads values from the host process),
# docker_env lets you specify exact key-value pairs — useful when Hermes
# runs as a systemd service without access to the user's shell environment.
# Example: {"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock"}
"docker_env": {},
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
@@ -305,7 +321,7 @@ DEFAULT_CONFIG = {
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30, # seconds increase for slow local models
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
},
"compression": {
"provider": "auto",
@@ -426,6 +442,11 @@ DEFAULT_CONFIG = {
"user_profile_enabled": True,
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# External memory provider plugin (empty = built-in only).
# Set to a provider name to activate: "openviking", "mem0",
# "hindsight", "holographic", "retaindb", "byterover".
# Only ONE external provider is allowed at a time.
"provider": "",
},
# Subagent delegation — override the provider:model used by delegate_task
@@ -516,8 +537,16 @@ DEFAULT_CONFIG = {
"wrap_response": True,
},
# Logging — controls file logging to ~/.hermes/logs/.
# agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
"logging": {
"level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING
"max_size_mb": 5, # Max size per log file before rotation
"backup_count": 3, # Number of rotated backup files to keep
},
# Config schema version - bump this when adding new required fields
"_config_version": 11,
"_config_version": 12,
}
# =============================================================================
@@ -532,6 +561,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"],
}
# Required environment variables with metadata for migration prompts.
@@ -560,6 +590,30 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"GOOGLE_API_KEY": {
"description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)",
"prompt": "Google AI Studio API key",
"url": "https://aistudio.google.com/app/apikey",
"password": True,
"category": "provider",
"advanced": True,
},
"GEMINI_API_KEY": {
"description": "Google AI Studio API key (alias for GOOGLE_API_KEY)",
"prompt": "Gemini API key",
"url": "https://aistudio.google.com/app/apikey",
"password": True,
"category": "provider",
"advanced": True,
},
"GEMINI_BASE_URL": {
"description": "Google AI Studio base URL override",
"prompt": "Gemini base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"GLM_API_KEY": {
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
"prompt": "Z.AI / GLM API key",
@@ -750,6 +804,38 @@ OPTIONAL_ENV_VARS = {
"category": "tool",
"advanced": True,
},
"FIRECRAWL_GATEWAY_URL": {
"description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
"prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_DOMAIN": {
"description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
"prompt": "Tool-gateway domain suffix",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_SCHEME": {
"description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
"prompt": "Tool-gateway URL scheme",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_USER_TOKEN": {
"description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
"prompt": "Tool-gateway user token",
"url": None,
"password": True,
"category": "tool",
"advanced": True,
},
"TAVILY_API_KEY": {
"description": "Tavily API key for AI-native web search, extract, and crawl",
"prompt": "Tavily API key",
@@ -782,6 +868,13 @@ OPTIONAL_ENV_VARS = {
"password": True,
"category": "tool",
},
"FIRECRAWL_BROWSER_TTL": {
"description": "Firecrawl browser session TTL in seconds (optional, default 300)",
"prompt": "Browser session TTL (seconds)",
"tools": ["browser_navigate", "browser_click"],
"password": False,
"category": "tool",
},
"CAMOFOX_URL": {
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
"prompt": "Camofox server URL",
@@ -962,6 +1055,38 @@ OPTIONAL_ENV_VARS = {
"password": False,
"category": "messaging",
},
"MATRIX_REQUIRE_MENTION": {
"description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.",
"prompt": "Require @mention in rooms (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_FREE_RESPONSE_ROOMS": {
"description": "Comma-separated Matrix room IDs where bot responds without @mention",
"prompt": "Free-response room IDs (comma-separated)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_AUTO_THREAD": {
"description": "Auto-create threads for messages in Matrix rooms (default: true)",
"prompt": "Auto-create threads in rooms (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_DEVICE_ID": {
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
"prompt": "Matrix device ID (stable across restarts)",
"url": None,
"password": False,
"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)",
@@ -1079,6 +1204,15 @@ OPTIONAL_ENV_VARS = {
},
}
if not _managed_nous_tools_enabled():
for _hidden_var in (
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
OPTIONAL_ENV_VARS.pop(_hidden_var, None)
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
"""
@@ -1145,6 +1279,43 @@ def get_missing_config_fields() -> List[Dict[str, Any]]:
return missing
def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
"""Return skill-declared config vars that are missing or empty in config.yaml.
Scans all enabled skills for ``metadata.hermes.config`` entries, then checks
which ones are absent or empty under ``skills.config.<key>`` in the user's
config.yaml. Returns a list of dicts suitable for prompting.
"""
try:
from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
except Exception:
return []
all_vars = discover_all_skill_config_vars()
if not all_vars:
return []
config = load_config()
missing: List[Dict[str, Any]] = []
for var in all_vars:
# Skill config is stored under skills.config.<logical_key>
storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
parts = storage_key.split(".")
current = config
value = None
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
value = current
else:
value = None
break
# Missing = key doesn't exist or is empty string
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(var)
return missing
def check_config_version() -> Tuple[int, int]:
"""
Check config version.
@@ -1157,6 +1328,182 @@ def check_config_version() -> Tuple[int, int]:
return current, latest
# =============================================================================
# Config structure validation
# =============================================================================
# Fields that are valid at root level of config.yaml
_KNOWN_ROOT_KEYS = {
"_config_version", "model", "providers", "fallback_model",
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "memory", "gateway",
}
# Valid fields inside a custom_providers list entry
_VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "models",
"context_length", "rate_limit_delay",
}
# Fields that look like they should be inside custom_providers, not at root
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
@dataclass
class ConfigIssue:
"""A detected config structure problem."""
severity: str # "error", "warning"
message: str
hint: str
def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]:
"""Validate config.yaml structure and return a list of detected issues.
Catches common YAML formatting mistakes that produce confusing runtime
errors (like "Unknown provider") instead of clear diagnostics.
Can be called with a pre-loaded config dict, or will load from disk.
"""
if config is None:
try:
config = load_config()
except Exception:
return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")]
issues: List[ConfigIssue] = []
# ── custom_providers must be a list, not a dict ──────────────────────
cp = config.get("custom_providers")
if cp is not None:
if isinstance(cp, dict):
issues.append(ConfigIssue(
"error",
"custom_providers is a dict — it must be a YAML list (items prefixed with '-')",
"Change to:\n"
" custom_providers:\n"
" - name: my-provider\n"
" base_url: https://...\n"
" api_key: ...",
))
# Check if dict keys look like they should be list-entry fields
cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()
suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS
if suspicious:
issues.append(ConfigIssue(
"warning",
f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields",
"These should be indented under a '- name: ...' list entry, not at root level",
))
elif isinstance(cp, list):
# Validate each entry in the list
for i, entry in enumerate(cp):
if not isinstance(entry, dict):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is not a dict (got {type(entry).__name__})",
"Each entry should have at minimum: name, base_url",
))
continue
if not entry.get("name"):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is missing 'name' field",
"Add a name, e.g.: name: my-provider",
))
if not entry.get("base_url"):
issues.append(ConfigIssue(
"warning",
f"custom_providers[{i}] is missing 'base_url' field",
"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1",
))
# ── fallback_model must be a top-level dict with provider + model ────
fb = config.get("fallback_model")
if fb is not None:
if not isinstance(fb, dict):
issues.append(ConfigIssue(
"error",
f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}",
"Change to:\n"
" fallback_model:\n"
" provider: openrouter\n"
" model: anthropic/claude-sonnet-4",
))
elif fb:
if not fb.get("provider"):
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'provider' field — fallback will be disabled",
"Add: provider: openrouter (or another provider)",
))
if not fb.get("model"):
issues.append(ConfigIssue(
"warning",
"fallback_model is missing 'model' field — fallback will be disabled",
"Add: model: anthropic/claude-sonnet-4 (or another model)",
))
# ── Check for fallback_model accidentally nested inside custom_providers ──
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
issues.append(ConfigIssue(
"error",
"fallback_model appears inside custom_providers instead of at root level",
"Move fallback_model to the top level of config.yaml (no indentation)",
))
# ── model section: should exist when custom_providers is configured ──
model_cfg = config.get("model")
if cp and not model_cfg:
issues.append(ConfigIssue(
"warning",
"custom_providers defined but no 'model' section — Hermes won't know which provider to use",
"Add a model section:\n"
" model:\n"
" provider: custom\n"
" default: your-model-name\n"
" base_url: https://...",
))
# ── Root-level keys that look misplaced ──────────────────────────────
for key in config:
if key.startswith("_"):
continue
if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
issues.append(ConfigIssue(
"warning",
f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
f"Move '{key}' under the appropriate section",
))
return issues
def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:
"""Print config structure warnings to stderr at startup.
Called early in CLI and gateway init so users see problems before
they hit cryptic "Unknown provider" errors. Prints nothing if
config is healthy.
"""
try:
issues = validate_config_structure(config)
except Exception:
return
if not issues:
return
import sys
lines = ["\033[33m⚠ Config issues detected in config.yaml:\033[0m"]
for ci in issues:
marker = "\033[31m✗\033[0m" if ci.severity == "error" else "\033[33m⚠\033[0m"
lines.append(f" {marker} {ci.message}")
lines.append(" \033[2mRun 'hermes doctor' for fix suggestions.\033[0m")
sys.stderr.write("\n".join(lines) + "\n\n")
def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
"""
Migrate config to latest version, prompting for new required fields.
@@ -1232,6 +1579,69 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
except Exception:
pass
# ── Version 11 → 12: migrate custom_providers list → providers dict ──
if current_ver < 12:
config = load_config()
custom_list = config.get("custom_providers")
if isinstance(custom_list, list) and custom_list:
providers_dict = config.get("providers", {})
if not isinstance(providers_dict, dict):
providers_dict = {}
migrated_count = 0
for entry in custom_list:
if not isinstance(entry, dict):
continue
old_name = entry.get("name", "")
old_url = entry.get("base_url", "") or entry.get("url", "") or ""
old_key = entry.get("api_key", "")
if not old_url:
continue # skip entries with no URL
# Generate a kebab-case key from the display name
key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
# Remove consecutive hyphens and trailing hyphens
while "--" in key:
key = key.replace("--", "-")
key = key.strip("-")
if not key:
# Fallback: derive from URL hostname
try:
from urllib.parse import urlparse
parsed = urlparse(old_url)
key = (parsed.hostname or "endpoint").replace(".", "-")
except Exception:
key = f"endpoint-{migrated_count}"
# Don't overwrite existing entries
if key in providers_dict:
key = f"{key}-{migrated_count}"
new_entry = {"api": old_url}
if old_name:
new_entry["name"] = old_name
if old_key and old_key not in ("no-key", "no-key-required", ""):
new_entry["api_key"] = old_key
# Carry over model and api_mode if present
if entry.get("model"):
new_entry["default_model"] = entry["model"]
if entry.get("api_mode"):
new_entry["transport"] = entry["api_mode"]
providers_dict[key] = new_entry
migrated_count += 1
if migrated_count > 0:
config["providers"] = providers_dict
# Remove the old list
del config["custom_providers"]
save_config(config)
if not quiet:
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
for key in list(providers_dict.keys())[-migrated_count:]:
ep = providers_dict[key]
print(f"{key}: {ep.get('api', '')}")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -1337,7 +1747,50 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
config = load_config()
config["_config_version"] = latest_ver
save_config(config)
# ── Skill-declared config vars ──────────────────────────────────────
# Skills can declare config.yaml settings they need via
# metadata.hermes.config in their SKILL.md frontmatter.
# Prompt for any that are missing/empty.
missing_skill_config = get_missing_skill_config_vars()
if missing_skill_config and interactive and not quiet:
print(f"\n {len(missing_skill_config)} skill setting(s) not configured:")
for var in missing_skill_config:
skill_name = var.get("skill", "unknown")
print(f"{var['key']}{var['description']} (from skill: {skill_name})")
print()
try:
answer = input(" Configure skill settings? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer in ("y", "yes"):
print()
config = load_config()
try:
from agent.skill_utils import SKILL_CONFIG_PREFIX
except Exception:
SKILL_CONFIG_PREFIX = "skills.config"
for var in missing_skill_config:
default = var.get("default", "")
default_hint = f" (default: {default})" if default else ""
value = input(f" {var['prompt']}{default_hint}: ").strip()
if not value and default:
value = str(default)
if value:
storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
_set_nested(config, storage_key, value)
results["config_added"].append(var["key"])
print(f" ✓ Saved {var['key']} = {value}")
else:
results["warnings"].append(
f"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later"
)
print()
save_config(config)
else:
print(" Set later with: hermes config set <key> <value>")
return results
@@ -1479,8 +1932,8 @@ _FALLBACK_COMMENT = """
#
# Supported providers:
# openrouter (OPENROUTER_API_KEY) — routes to any model
# openai-codex (OAuth — hermes login) — OpenAI Codex
# nous (OAuth — hermes login) — Nous Portal
# openai-codex (OAuth — hermes auth) — OpenAI Codex
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# minimax (MINIMAX_API_KEY) — MiniMax
@@ -1522,8 +1975,8 @@ _COMMENTED_SECTIONS = """
#
# Supported providers:
# openrouter (OPENROUTER_API_KEY) — routes to any model
# openai-codex (OAuth — hermes login) — OpenAI Codex
# nous (OAuth — hermes login) — Nous Portal
# openai-codex (OAuth — hermes auth) — OpenAI Codex
# nous (OAuth — hermes auth) — Nous Portal
# zai (ZAI_API_KEY) — Z.AI / GLM
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
# minimax (MINIMAX_API_KEY) — MiniMax
@@ -1756,6 +2209,51 @@ def save_env_value(key: str, value: str):
pass
def remove_env_value(key: str) -> bool:
"""Remove a key from ~/.hermes/.env and os.environ.
Returns True if the key was found and removed, False otherwise.
"""
if is_managed():
managed_error(f"remove {key}")
return False
if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}")
env_path = get_env_path()
if not env_path.exists():
os.environ.pop(key, None)
return False
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
with open(env_path, **read_kw) as f:
lines = f.readlines()
lines = _sanitize_env_lines(lines)
new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
found = len(new_lines) < len(lines)
if found:
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
try:
with os.fdopen(fd, 'w', **write_kw) as f:
f.writelines(new_lines)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ.pop(key, None)
return found
def save_anthropic_oauth_token(value: str, save_fn=None):
"""Persist an Anthropic OAuth/setup token and clear the API-key slot."""
writer = save_fn or save_env_value
@@ -1946,6 +2444,23 @@ def show_config():
print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}")
print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}")
# Skill config
try:
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
skill_vars = discover_all_skill_config_vars()
if skill_vars:
resolved = resolve_skill_config_values(skill_vars)
print()
print(color("◆ Skill Settings", Colors.CYAN, Colors.BOLD))
for var in skill_vars:
key = var["key"]
value = resolved.get(key, "")
skill_name = var.get("skill", "")
display_val = str(value) if value else color("(not set)", Colors.DIM)
print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}")
except Exception:
pass
print()
print(color("" * 60, Colors.DIM))
print(color(" hermes config edit # Edit config file", Colors.DIM))
@@ -1994,7 +2509,9 @@ def set_config_value(key: str, value: str):
# Check if it's an API key (goes to .env)
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
@@ -2050,6 +2567,7 @@ def set_config_value(key: str, value: str):
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
_config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
+10
View File
@@ -90,6 +90,9 @@ def cron_list(show_all: bool = False):
print(f" Deliver: {deliver_str}")
if skills:
print(f" Skills: {', '.join(skills)}")
script = job.get("script")
if script:
print(f" Script: {script}")
print()
from hermes_cli.gateway import find_gateway_pids
@@ -149,6 +152,7 @@ def cron_create(args):
repeat=getattr(args, "repeat", None),
skill=getattr(args, "skill", None),
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
script=getattr(args, "script", None),
)
if not result.get("success"):
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
@@ -158,6 +162,9 @@ def cron_create(args):
print(f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
job_data = result.get("job", {})
if job_data.get("script"):
print(f" Script: {job_data['script']}")
print(f" Next run: {result['next_run_at']}")
return 0
@@ -195,6 +202,7 @@ def cron_edit(args):
deliver=getattr(args, "deliver", None),
repeat=getattr(args, "repeat", None),
skills=final_skills,
script=getattr(args, "script", None),
)
if not result.get("success"):
print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
@@ -208,6 +216,8 @@ def cron_edit(args):
print(f" Skills: {', '.join(updated['skills'])}")
else:
print(" Skills: none")
if updated.get("script"):
print(f" Script: {updated['script']}")
return 0
+148 -9
View File
@@ -37,6 +37,7 @@ _PROVIDER_ENV_HINTS = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN",
"OPENAI_BASE_URL",
"NOUS_API_KEY",
"GLM_API_KEY",
"ZAI_API_KEY",
"Z_AI_API_KEY",
@@ -44,6 +45,12 @@ _PROVIDER_ENV_HINTS = (
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"KILOCODE_API_KEY",
"DEEPSEEK_API_KEY",
"DASHSCOPE_API_KEY",
"HF_TOKEN",
"AI_GATEWAY_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
)
@@ -55,7 +62,7 @@ def _has_provider_env_config(content: str) -> bool:
def _honcho_is_configured_for_doctor() -> bool:
"""Return True when Honcho is configured, even if this process has no active session."""
try:
from honcho_integration.client import HonchoClientConfig
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
@@ -257,7 +264,79 @@ def run_doctor(args):
manual_issues.append(f"Create {_DHH}/config.yaml manually")
else:
check_warn("config.yaml not found", "(using defaults)")
# Check config version and stale keys
config_path = HERMES_HOME / 'config.yaml'
if config_path.exists():
try:
from hermes_cli.config import check_config_version, migrate_config
current_ver, latest_ver = check_config_version()
if current_ver < latest_ver:
check_warn(
f"Config version outdated (v{current_ver} → v{latest_ver})",
"(new settings available)"
)
if should_fix:
try:
migrate_config(interactive=False, quiet=False)
check_ok("Config migrated to latest version")
fixed_count += 1
except Exception as mig_err:
check_warn(f"Auto-migration failed: {mig_err}")
issues.append("Run 'hermes setup' to migrate config")
else:
issues.append("Run 'hermes doctor --fix' or 'hermes setup' to migrate config")
else:
check_ok(f"Config version up to date (v{current_ver})")
except Exception:
pass
# Detect stale root-level model keys (known bug source — PR #4329)
try:
import yaml
with open(config_path) as f:
raw_config = yaml.safe_load(f) or {}
stale_root_keys = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)]
if stale_root_keys:
check_warn(
f"Stale root-level config keys: {', '.join(stale_root_keys)}",
"(should be under 'model:' section)"
)
if should_fix:
model_section = raw_config.setdefault("model", {})
for k in stale_root_keys:
if not model_section.get(k):
model_section[k] = raw_config.pop(k)
else:
raw_config.pop(k)
with open(config_path, "w") as f:
yaml.dump(raw_config, f, default_flow_style=False)
check_ok("Migrated stale root-level keys into model section")
fixed_count += 1
else:
issues.append("Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'")
except Exception:
pass
# Validate config structure (catches malformed custom_providers, etc.)
try:
from hermes_cli.config import validate_config_structure
config_issues = validate_config_structure()
if config_issues:
print()
print(color("◆ Config Structure", Colors.CYAN, Colors.BOLD))
for ci in config_issues:
if ci.severity == "error":
check_fail(ci.message)
else:
check_warn(ci.message)
# Show the hint indented
for hint_line in ci.hint.splitlines():
check_info(hint_line)
issues.append(ci.message)
except Exception:
pass
# =========================================================================
# Check: Auth providers
# =========================================================================
@@ -380,6 +459,31 @@ def run_doctor(args):
else:
check_info(f"{_DHH}/state.db not created yet (will be created on first session)")
# Check WAL file size (unbounded growth indicates missed checkpoints)
wal_path = hermes_home / "state.db-wal"
if wal_path.exists():
try:
wal_size = wal_path.stat().st_size
if wal_size > 50 * 1024 * 1024: # 50 MB
check_warn(
f"WAL file is large ({wal_size // (1024*1024)} MB)",
"(may indicate missed checkpoints)"
)
if should_fix:
import sqlite3
conn = sqlite3.connect(str(state_db_path))
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.close()
new_size = wal_path.stat().st_size if wal_path.exists() else 0
check_ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)")
fixed_count += 1
else:
issues.append("Large WAL file — run 'hermes doctor --fix' to checkpoint")
elif wal_size > 10 * 1024 * 1024: # 10 MB
check_info(f"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)")
except Exception:
pass
_check_gateway_service_linger(issues)
# =========================================================================
@@ -566,17 +670,22 @@ def run_doctor(args):
except Exception as e:
print(f"\r {color('', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} ")
# -- API-key providers (Z.AI/GLM, Kimi, MiniMax, MiniMax-CN) --
# -- API-key providers --
# Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
# If supports_models_endpoint is False, we skip the health check and just show "configured"
_apikey_providers = [
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax APIs don't support /models endpoint — https://github.com/NousResearch/hermes-agent/issues/811
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),
("OpenCode Go", ("OPENCODE_GO_API_KEY",), "https://opencode.ai/zen/go/v1/models", "OPENCODE_GO_BASE_URL", True),
]
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
_key = ""
@@ -709,25 +818,25 @@ def run_doctor(args):
print(color("◆ Honcho Memory", Colors.CYAN, Colors.BOLD))
try:
from honcho_integration.client import HonchoClientConfig, resolve_config_path
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
hcfg = HonchoClientConfig.from_global_config()
_honcho_cfg_path = resolve_config_path()
if not _honcho_cfg_path.exists():
check_warn("Honcho config not found", "run: hermes honcho setup")
check_warn("Honcho config not found", "run: hermes memory setup")
elif not hcfg.enabled:
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
elif not (hcfg.api_key or hcfg.base_url):
check_fail("Honcho API key or base URL not set", "run: hermes honcho setup")
issues.append("No Honcho API key — run 'hermes honcho setup'")
check_fail("Honcho API key or base URL not set", "run: hermes memory setup")
issues.append("No Honcho API key — run 'hermes memory setup'")
else:
from honcho_integration.client import get_honcho_client, reset_honcho_client
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
reset_honcho_client()
try:
get_honcho_client(hcfg)
check_ok(
"Honcho connected",
f"workspace={hcfg.workspace_id} mode={hcfg.memory_mode} freq={hcfg.write_frequency}",
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}",
)
except Exception as _e:
check_fail("Honcho connection failed", str(_e))
@@ -737,6 +846,36 @@ def run_doctor(args):
except Exception as _e:
check_warn("Honcho check failed", str(_e))
# =========================================================================
# Mem0 memory
# =========================================================================
print()
print(color("◆ Mem0 Memory", Colors.CYAN, Colors.BOLD))
try:
from plugins.memory.mem0 import _load_config as _load_mem0_config
mem0_cfg = _load_mem0_config()
mem0_key = mem0_cfg.get("api_key", "")
if mem0_key:
check_ok("Mem0 API key configured")
check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}")
# Check if mem0.json exists but is missing api_key (the bug we fixed)
mem0_json = HERMES_HOME / "mem0.json"
if mem0_json.exists():
try:
import json as _json
file_cfg = _json.loads(mem0_json.read_text())
if not file_cfg.get("api_key") and mem0_key:
check_info("api_key from .env (not in mem0.json) — this is fine")
except Exception:
pass
else:
check_warn("Mem0 not configured", "(set MEM0_API_KEY in .env or run hermes memory setup)")
except ImportError:
check_warn("Mem0 plugin not loadable", "(optional)")
except Exception as _e:
check_warn("Mem0 check failed", str(_e))
# =========================================================================
# Profiles
# =========================================================================
+269 -95
View File
@@ -28,9 +28,78 @@ from hermes_cli.colors import Colors, color
# Process Management (for manual gateway runs)
# =============================================================================
def find_gateway_pids() -> list:
"""Find PIDs of running gateway processes."""
def _get_service_pids() -> set:
"""Return PIDs currently managed by systemd or launchd gateway services.
Used to avoid killing freshly-restarted service processes when sweeping
for stale manual gateway processes after a service restart. Relies on the
service manager having committed the new PID before the restart command
returns (true for both systemd and launchd in practice).
"""
pids: set = set()
# --- systemd (Linux): user and system scopes ---
if is_linux():
for scope_args in [["systemctl", "--user"], ["systemctl"]]:
try:
result = subprocess.run(
scope_args + ["list-units", "hermes-gateway*",
"--plain", "--no-legend", "--no-pager"],
capture_output=True, text=True, timeout=5,
)
for line in result.stdout.strip().splitlines():
parts = line.split()
if not parts or not parts[0].endswith(".service"):
continue
svc = parts[0]
try:
show = subprocess.run(
scope_args + ["show", svc,
"--property=MainPID", "--value"],
capture_output=True, text=True, timeout=5,
)
pid = int(show.stdout.strip())
if pid > 0:
pids.add(pid)
except (ValueError, subprocess.TimeoutExpired):
pass
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# --- launchd (macOS) ---
if is_macos():
try:
label = get_launchd_label()
result = subprocess.run(
["launchctl", "list", label],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
# Output: "PID\tStatus\tLabel" header, then one data line
for line in result.stdout.strip().splitlines():
parts = line.split()
if len(parts) >= 3 and parts[2] == label:
try:
pid = int(parts[0])
if pid > 0:
pids.add(pid)
except ValueError:
pass
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return pids
def find_gateway_pids(exclude_pids: set | None = None) -> list:
"""Find PIDs of running gateway processes.
Args:
exclude_pids: PIDs to exclude from the result (e.g. service-managed
PIDs that should not be killed during a stale-process sweep).
"""
pids = []
_exclude = exclude_pids or set()
patterns = [
"hermes_cli.main gateway",
"hermes_cli/main.py gateway",
@@ -43,7 +112,7 @@ def find_gateway_pids() -> list:
# Windows: use wmic to search command lines
result = subprocess.run(
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
capture_output=True, text=True
capture_output=True, text=True, timeout=10
)
# Parse WMIC LIST output: blocks of "CommandLine=...\nProcessId=...\n"
current_cmd = ""
@@ -56,7 +125,7 @@ def find_gateway_pids() -> list:
if any(p in current_cmd for p in patterns):
try:
pid = int(pid_str)
if pid != os.getpid() and pid not in pids:
if pid != os.getpid() and pid not in pids and pid not in _exclude:
pids.append(pid)
except ValueError:
pass
@@ -65,7 +134,8 @@ def find_gateway_pids() -> list:
result = subprocess.run(
["ps", "aux"],
capture_output=True,
text=True
text=True,
timeout=10,
)
for line in result.stdout.split('\n'):
# Skip grep and current process
@@ -77,7 +147,7 @@ def find_gateway_pids() -> list:
if len(parts) > 1:
try:
pid = int(parts[1])
if pid not in pids:
if pid not in pids and pid not in _exclude:
pids.append(pid)
except ValueError:
continue
@@ -88,9 +158,15 @@ def find_gateway_pids() -> list:
return pids
def kill_gateway_processes(force: bool = False) -> int:
"""Kill any running gateway processes. Returns count killed."""
pids = find_gateway_pids()
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None) -> int:
"""Kill any running gateway processes. Returns count killed.
Args:
force: Use SIGKILL instead of SIGTERM.
exclude_pids: PIDs to skip (e.g. service-managed PIDs that were just
restarted and should not be killed).
"""
pids = find_gateway_pids(exclude_pids=exclude_pids)
killed = 0
for pid in pids:
@@ -109,6 +185,43 @@ def kill_gateway_processes(force: bool = False) -> int:
return killed
def stop_profile_gateway() -> bool:
"""Stop only the gateway for the current profile (HERMES_HOME-scoped).
Uses the PID file written by start_gateway(), so it only kills the
gateway belonging to this profile not gateways from other profiles.
Returns True if a process was stopped, False if none was found.
"""
try:
from gateway.status import get_running_pid, remove_pid_file
except ImportError:
return False
pid = get_running_pid()
if pid is None:
return False
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass # Already gone
except PermissionError:
print(f"⚠ Permission denied to kill PID {pid}")
return False
# Wait briefly for it to exit
import time as _time
for _ in range(20):
try:
os.kill(pid, 0)
_time.sleep(0.5)
except (ProcessLookupError, PermissionError):
break
remove_pid_file()
return True
def is_linux() -> bool:
return sys.platform.startswith('linux')
@@ -258,8 +371,11 @@ def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str,
username = (run_as_user or os.getenv("SUDO_USER") or os.getenv("USER") or os.getenv("LOGNAME") or getpass.getuser()).strip()
if not username:
raise ValueError("Could not determine which user the gateway service should run as")
if username == "root" and not run_as_user:
raise ValueError("Refusing to install the gateway system service as root; pass --run-as-user root to override (e.g. in LXC containers)")
if username == "root":
raise ValueError("Refusing to install the gateway system service as root; pass --run-as USER")
print_warning("Installing gateway service to run as root.")
print_info(" This is fine for LXC/container environments but not recommended on bare-metal hosts.")
try:
user_info = pwd.getpwnam(username)
@@ -321,9 +437,9 @@ def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, b
while True:
run_as_user = prompt(" Run the system gateway service as which user?", default="")
run_as_user = (run_as_user or "").strip()
if run_as_user and run_as_user != "root":
if run_as_user:
break
print_error(" Enter a non-root username.")
print_error(" Enter a username.")
systemd_install(force=force, system=True, run_as_user=run_as_user)
return scope, True
@@ -362,6 +478,7 @@ def get_systemd_linger_status() -> tuple[bool | None, str]:
capture_output=True,
text=True,
check=False,
timeout=10,
)
except Exception as e:
return None, str(e)
@@ -596,7 +713,7 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
expected_user = _read_systemd_user_from_unit(unit_path) if system else None
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding="utf-8")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True)
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install")
return True
@@ -647,6 +764,7 @@ def _ensure_linger_enabled() -> None:
capture_output=True,
text=True,
check=False,
timeout=30,
)
except Exception as e:
_print_linger_enable_warning(username, str(e))
@@ -677,7 +795,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
if not systemd_unit_is_current(system=system):
print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service definition updated")
return
print(f"Service already installed at: {unit_path}")
@@ -688,8 +806,8 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30)
print()
print(f"{_service_scope_label(system).capitalize()} service installed and enabled!")
@@ -715,15 +833,15 @@ def systemd_uninstall(system: bool = False):
if system:
_require_root_for_system_service("uninstall")
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False)
subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False)
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False, timeout=90)
subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False, timeout=30)
unit_path = get_systemd_unit_path(system=system)
if unit_path.exists():
unit_path.unlink()
print(f"✓ Removed {unit_path}")
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True)
subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service uninstalled")
@@ -732,7 +850,7 @@ def systemd_start(system: bool = False):
if system:
_require_root_for_system_service("start")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True)
subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service started")
@@ -741,7 +859,7 @@ def systemd_stop(system: bool = False):
system = _select_systemd_scope(system)
if system:
_require_root_for_system_service("stop")
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True)
subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service stopped")
@@ -751,7 +869,7 @@ def systemd_restart(system: bool = False):
if system:
_require_root_for_system_service("restart")
refresh_systemd_unit_if_needed(system=system)
subprocess.run(_systemctl_cmd(system) + ["restart", get_service_name()], check=True)
subprocess.run(_systemctl_cmd(system) + ["restart", get_service_name()], check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service restarted")
@@ -778,12 +896,14 @@ def systemd_status(deep: bool = False, system: bool = False):
subprocess.run(
_systemctl_cmd(system) + ["status", get_service_name(), "--no-pager"],
capture_output=False,
timeout=10,
)
result = subprocess.run(
_systemctl_cmd(system) + ["is-active", get_service_name()],
capture_output=True,
text=True,
timeout=10,
)
status = result.stdout.strip()
@@ -820,7 +940,7 @@ def systemd_status(deep: bool = False, system: bool = False):
if deep:
print()
print("Recent logs:")
subprocess.run(_journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"])
subprocess.run(_journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"], timeout=10)
# =============================================================================
@@ -833,6 +953,11 @@ def get_launchd_label() -> str:
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
def _launchd_domain() -> str:
import os
return f"gui/{os.getuid()}"
def generate_launchd_plist() -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
@@ -923,18 +1048,19 @@ def launchd_plist_is_current() -> bool:
def refresh_launchd_plist_if_needed() -> bool:
"""Rewrite the installed launchd plist when the generated definition has changed.
Unlike systemd, launchd picks up plist changes on the next ``launchctl stop``/
``launchctl start`` cycle no daemon-reload is needed. We still unload/reload
to make launchd re-read the updated plist immediately.
Unlike systemd, launchd picks up plist changes on the next ``launchctl kill``/
``launchctl kickstart`` cycle no daemon-reload is needed. We still bootout/
bootstrap to make launchd re-read the updated plist immediately.
"""
plist_path = get_launchd_plist_path()
if not plist_path.exists() or launchd_plist_is_current():
return False
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
# Unload/reload so launchd picks up the new definition
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
subprocess.run(["launchctl", "load", str(plist_path)], check=False)
label = get_launchd_label()
# Bootout/bootstrap so launchd picks up the new definition
subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90)
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=False, timeout=30)
print("↻ Updated gateway launchd service definition to match the current Hermes install")
return True
@@ -956,7 +1082,7 @@ def launchd_install(force: bool = False):
print(f"Installing launchd service to: {plist_path}")
plist_path.write_text(generate_launchd_plist())
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
print()
print("✓ Service installed and loaded!")
@@ -968,7 +1094,8 @@ def launchd_install(force: bool = False):
def launchd_uninstall():
plist_path = get_launchd_plist_path()
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
label = get_launchd_label()
subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90)
if plist_path.exists():
plist_path.unlink()
@@ -985,25 +1112,25 @@ def launchd_start():
print("↻ launchd plist missing; regenerating service definition")
plist_path.parent.mkdir(parents=True, exist_ok=True)
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
subprocess.run(["launchctl", "start", label], check=True)
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
print("✓ Service started")
return
refresh_launchd_plist_if_needed()
try:
subprocess.run(["launchctl", "start", label], check=True)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
except subprocess.CalledProcessError as e:
if e.returncode != 3:
if e.returncode not in (3, 113):
raise
print("↻ launchd job was unloaded; reloading service definition")
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
subprocess.run(["launchctl", "start", label], check=True)
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
print("✓ Service started")
def launchd_stop():
label = get_launchd_label()
subprocess.run(["launchctl", "stop", label], check=True)
subprocess.run(["launchctl", "kill", "SIGTERM", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
print("✓ Service stopped")
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
@@ -1047,23 +1174,39 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
def launchd_restart():
label = get_launchd_label()
target = f"{_launchd_domain()}/{label}"
# Use kickstart -k so launchd performs an atomic kill+restart.
# A two-step stop/start from inside the gateway's own process tree
# would kill the shell before the start command is reached.
try:
launchd_stop()
subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90)
print("✓ Service restarted")
except subprocess.CalledProcessError as e:
if e.returncode != 3:
if e.returncode not in (3, 113):
raise
print("↻ launchd job was unloaded; skipping stop")
_wait_for_gateway_exit()
launchd_start()
# Job not loaded — bootstrap and start fresh
print("↻ launchd job was unloaded; reloading")
plist_path = get_launchd_plist_path()
subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30)
subprocess.run(["launchctl", "kickstart", target], check=True, timeout=30)
print("✓ Service restarted")
def launchd_status(deep: bool = False):
plist_path = get_launchd_plist_path()
label = get_launchd_label()
result = subprocess.run(
["launchctl", "list", label],
capture_output=True,
text=True
)
try:
result = subprocess.run(
["launchctl", "list", label],
capture_output=True,
text=True,
timeout=10,
)
loaded = result.returncode == 0
loaded_output = result.stdout
except subprocess.TimeoutExpired:
loaded = False
loaded_output = ""
print(f"Launchd plist: {plist_path}")
if launchd_plist_is_current():
@@ -1071,10 +1214,10 @@ def launchd_status(deep: bool = False):
else:
print("⚠ Service definition is stale relative to the current Hermes install")
print(" Run: hermes gateway start")
if result.returncode == 0:
if loaded:
print("✓ Gateway service is loaded")
print(result.stdout)
print(loaded_output)
else:
print("✗ Gateway service is not loaded")
print(" Service definition exists locally but launchd has not loaded it.")
@@ -1085,7 +1228,7 @@ def launchd_status(deep: bool = False):
if log_file.exists():
print()
print("Recent logs:")
subprocess.run(["tail", "-20", str(log_file)])
subprocess.run(["tail", "-20", str(log_file)], timeout=10)
# =============================================================================
@@ -1602,28 +1745,37 @@ def _is_service_running() -> bool:
system_unit_exists = get_systemd_unit_path(system=True).exists()
if user_unit_exists:
result = subprocess.run(
_systemctl_cmd(False) + ["is-active", get_service_name()],
capture_output=True, text=True
)
if result.stdout.strip() == "active":
return True
try:
result = subprocess.run(
_systemctl_cmd(False) + ["is-active", get_service_name()],
capture_output=True, text=True, timeout=10,
)
if result.stdout.strip() == "active":
return True
except subprocess.TimeoutExpired:
pass
if system_unit_exists:
result = subprocess.run(
_systemctl_cmd(True) + ["is-active", get_service_name()],
capture_output=True, text=True
)
if result.stdout.strip() == "active":
return True
try:
result = subprocess.run(
_systemctl_cmd(True) + ["is-active", get_service_name()],
capture_output=True, text=True, timeout=10,
)
if result.stdout.strip() == "active":
return True
except subprocess.TimeoutExpired:
pass
return False
elif is_macos() and get_launchd_plist_path().exists():
result = subprocess.run(
["launchctl", "list", get_launchd_label()],
capture_output=True, text=True
)
return result.returncode == 0
try:
result = subprocess.run(
["launchctl", "list", get_launchd_label()],
capture_output=True, text=True, timeout=10,
)
return result.returncode == 0
except subprocess.TimeoutExpired:
return False
# Check for manual processes
return len(find_gateway_pids()) > 0
@@ -1828,7 +1980,7 @@ def gateway_setup():
elif is_macos():
launchd_restart()
else:
kill_gateway_processes()
stop_profile_gateway()
print_info("Start manually: hermes gateway")
except subprocess.CalledProcessError as e:
print_error(f" Restart failed: {e}")
@@ -1942,31 +2094,54 @@ def gateway_command(args):
sys.exit(1)
elif subcmd == "stop":
# Try service first, then sweep any stray/manual gateway processes.
service_available = False
stop_all = getattr(args, 'all', False)
system = getattr(args, 'system', False)
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
try:
systemd_stop(system=system)
service_available = True
except subprocess.CalledProcessError:
pass # Fall through to process kill
elif is_macos() and get_launchd_plist_path().exists():
try:
launchd_stop()
service_available = True
except subprocess.CalledProcessError:
pass
killed = kill_gateway_processes()
if not service_available:
if killed:
print(f"✓ Stopped {killed} gateway process(es)")
if stop_all:
# --all: kill every gateway process on the machine
service_available = False
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
try:
systemd_stop(system=system)
service_available = True
except subprocess.CalledProcessError:
pass
elif is_macos() and get_launchd_plist_path().exists():
try:
launchd_stop()
service_available = True
except subprocess.CalledProcessError:
pass
killed = kill_gateway_processes()
total = killed + (1 if service_available else 0)
if total:
print(f"✓ Stopped {total} gateway process(es) across all profiles")
else:
print("✗ No gateway processes found")
elif killed:
print(f"✓ Stopped {killed} additional manual gateway process(es)")
else:
# Default: stop only the current profile's gateway
service_available = False
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
try:
systemd_stop(system=system)
service_available = True
except subprocess.CalledProcessError:
pass
elif is_macos() and get_launchd_plist_path().exists():
try:
launchd_stop()
service_available = True
except subprocess.CalledProcessError:
pass
if not service_available:
# No systemd/launchd — use profile-scoped PID file
if stop_profile_gateway():
print("✓ Stopped gateway for this profile")
else:
print("✗ No gateway running for this profile")
else:
print(f"✓ Stopped {get_service_name()} service")
elif subcmd == "restart":
# Try service first, fall back to killing and restarting
@@ -2013,10 +2188,9 @@ def gateway_command(args):
print(" Fix the service, then retry: hermes gateway start")
sys.exit(1)
# Manual restart: kill existing processes
killed = kill_gateway_processes()
if killed:
print(f"✓ Stopped {killed} gateway process(es)")
# Manual restart: stop only this profile's gateway
if stop_profile_gateway():
print("✓ Stopped gateway for this profile")
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
+336
View File
@@ -0,0 +1,336 @@
"""``hermes logs`` — view and filter Hermes log files.
Supports tailing, following, session filtering, level filtering, and
relative time ranges. All log files live under ``~/.hermes/logs/``.
Usage examples::
hermes logs # last 50 lines of agent.log
hermes logs -f # follow agent.log in real time
hermes logs errors # last 50 lines of errors.log
hermes logs gateway -n 100 # last 100 lines of gateway.log
hermes logs --level WARNING # only WARNING+ lines
hermes logs --session abc123 # filter by session ID substring
hermes logs --since 1h # lines from the last hour
hermes logs --since 30m -f # follow, starting 30 min ago
"""
import os
import re
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_home, display_hermes_home
# Known log files (name → filename)
LOG_FILES = {
"agent": "agent.log",
"errors": "errors.log",
"gateway": "gateway.log",
}
# Log line timestamp regex — matches "2026-04-05 22:35:00,123" or
# "2026-04-05 22:35:00" at the start of a line.
_TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})")
# Level extraction — matches " INFO ", " WARNING ", " ERROR ", " DEBUG ", " CRITICAL "
_LEVEL_RE = re.compile(r"\s(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s")
# Level ordering for >= filtering
_LEVEL_ORDER = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3, "CRITICAL": 4}
def _parse_since(since_str: str) -> Optional[datetime]:
"""Parse a relative time string like '1h', '30m', '2d' into a datetime cutoff.
Returns None if the string can't be parsed.
"""
since_str = since_str.strip().lower()
match = re.match(r"^(\d+)\s*([smhd])$", since_str)
if not match:
return None
value = int(match.group(1))
unit = match.group(2)
delta = {
"s": timedelta(seconds=value),
"m": timedelta(minutes=value),
"h": timedelta(hours=value),
"d": timedelta(days=value),
}[unit]
return datetime.now() - delta
def _parse_line_timestamp(line: str) -> Optional[datetime]:
"""Extract timestamp from a log line. Returns None if not parseable."""
m = _TS_RE.match(line)
if not m:
return None
try:
return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def _extract_level(line: str) -> Optional[str]:
"""Extract the log level from a line."""
m = _LEVEL_RE.search(line)
return m.group(1) if m else None
def _matches_filters(
line: str,
*,
min_level: Optional[str] = None,
session_filter: Optional[str] = None,
since: Optional[datetime] = None,
) -> bool:
"""Check if a log line passes all active filters."""
if since is not None:
ts = _parse_line_timestamp(line)
if ts is not None and ts < since:
return False
if min_level is not None:
level = _extract_level(line)
if level is not None:
if _LEVEL_ORDER.get(level, 0) < _LEVEL_ORDER.get(min_level, 0):
return False
if session_filter is not None:
if session_filter not in line:
return False
return True
def tail_log(
log_name: str = "agent",
*,
num_lines: int = 50,
follow: bool = False,
level: Optional[str] = None,
session: Optional[str] = None,
since: Optional[str] = None,
) -> None:
"""Read and display log lines, optionally following in real time.
Parameters
----------
log_name
Which log to read: ``"agent"``, ``"errors"``, ``"gateway"``.
num_lines
Number of recent lines to show (before follow starts).
follow
If True, keep watching for new lines (Ctrl+C to stop).
level
Minimum log level to show (e.g. ``"WARNING"``).
session
Session ID substring to filter on.
since
Relative time string (e.g. ``"1h"``, ``"30m"``).
"""
filename = LOG_FILES.get(log_name)
if filename is None:
print(f"Unknown log: {log_name!r}. Available: {', '.join(sorted(LOG_FILES))}")
sys.exit(1)
log_path = get_hermes_home() / "logs" / filename
if not log_path.exists():
print(f"Log file not found: {log_path}")
print(f"(Logs are created when Hermes runs — try 'hermes chat' first)")
sys.exit(1)
# Parse --since into a datetime cutoff
since_dt = None
if since:
since_dt = _parse_since(since)
if since_dt is None:
print(f"Invalid --since value: {since!r}. Use format like '1h', '30m', '2d'.")
sys.exit(1)
min_level = level.upper() if level else None
if min_level and min_level not in _LEVEL_ORDER:
print(f"Invalid --level: {level!r}. Use DEBUG, INFO, WARNING, ERROR, or CRITICAL.")
sys.exit(1)
has_filters = min_level is not None or session is not None or since_dt is not None
# Read and display the tail
try:
lines = _read_tail(log_path, num_lines, has_filters=has_filters,
min_level=min_level, session_filter=session,
since=since_dt)
except PermissionError:
print(f"Permission denied: {log_path}")
sys.exit(1)
# Print header
filter_parts = []
if min_level:
filter_parts.append(f"level>={min_level}")
if session:
filter_parts.append(f"session={session}")
if since:
filter_parts.append(f"since={since}")
filter_desc = f" [{', '.join(filter_parts)}]" if filter_parts else ""
if follow:
print(f"--- {display_hermes_home()}/logs/{filename}{filter_desc} (Ctrl+C to stop) ---")
else:
print(f"--- {display_hermes_home()}/logs/{filename}{filter_desc} (last {num_lines}) ---")
for line in lines:
print(line, end="")
if not follow:
return
# Follow mode — poll for new content
try:
_follow_log(log_path, min_level=min_level, session_filter=session,
since=since_dt)
except KeyboardInterrupt:
print("\n--- stopped ---")
def _read_tail(
path: Path,
num_lines: int,
*,
has_filters: bool = False,
min_level: Optional[str] = None,
session_filter: Optional[str] = None,
since: Optional[datetime] = None,
) -> list:
"""Read the last *num_lines* matching lines from a log file.
When filters are active, we read more raw lines to find enough matches.
"""
if has_filters:
# Read more lines to ensure we get enough after filtering.
# For large files, read last 10K lines and filter down.
raw_lines = _read_last_n_lines(path, max(num_lines * 20, 2000))
filtered = [
l for l in raw_lines
if _matches_filters(l, min_level=min_level,
session_filter=session_filter, since=since)
]
return filtered[-num_lines:]
else:
return _read_last_n_lines(path, num_lines)
def _read_last_n_lines(path: Path, n: int) -> list:
"""Efficiently read the last N lines from a file.
For files under 1MB, reads the whole file (fast, simple).
For larger files, reads chunks from the end.
"""
try:
size = path.stat().st_size
if size == 0:
return []
# For files up to 1MB, just read the whole thing — simple and correct.
if size <= 1_048_576:
with open(path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
return all_lines[-n:]
# For large files, read chunks from the end.
with open(path, "rb") as f:
chunk_size = 8192
lines = []
pos = size
while pos > 0 and len(lines) <= n + 1:
read_size = min(chunk_size, pos)
pos -= read_size
f.seek(pos)
chunk = f.read(read_size)
chunk_lines = chunk.split(b"\n")
if lines:
# Merge the last partial line of the new chunk with the
# first partial line of what we already have.
lines[0] = chunk_lines[-1] + lines[0]
lines = chunk_lines[:-1] + lines
else:
lines = chunk_lines
chunk_size = min(chunk_size * 2, 65536)
# Decode and return last N non-empty lines.
decoded = []
for raw in lines:
if not raw.strip():
continue
try:
decoded.append(raw.decode("utf-8", errors="replace") + "\n")
except Exception:
decoded.append(raw.decode("latin-1") + "\n")
return decoded[-n:]
except Exception:
# Fallback: read entire file
with open(path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
return all_lines[-n:]
def _follow_log(
path: Path,
*,
min_level: Optional[str] = None,
session_filter: Optional[str] = None,
since: Optional[datetime] = None,
) -> None:
"""Poll a log file for new content and print matching lines."""
with open(path, "r", encoding="utf-8", errors="replace") as f:
# Seek to end
f.seek(0, 2)
while True:
line = f.readline()
if line:
if _matches_filters(line, min_level=min_level,
session_filter=session_filter, since=since):
print(line, end="")
sys.stdout.flush()
else:
time.sleep(0.3)
def list_logs() -> None:
"""Print available log files with sizes."""
log_dir = get_hermes_home() / "logs"
if not log_dir.exists():
print(f"No logs directory at {display_hermes_home()}/logs/")
return
print(f"Log files in {display_hermes_home()}/logs/:\n")
found = False
for entry in sorted(log_dir.iterdir()):
if entry.is_file() and entry.suffix == ".log":
size = entry.stat().st_size
mtime = datetime.fromtimestamp(entry.stat().st_mtime)
if size < 1024:
size_str = f"{size}B"
elif size < 1024 * 1024:
size_str = f"{size / 1024:.1f}KB"
else:
size_str = f"{size / (1024 * 1024):.1f}MB"
age = datetime.now() - mtime
if age.total_seconds() < 60:
age_str = "just now"
elif age.total_seconds() < 3600:
age_str = f"{int(age.total_seconds() / 60)}m ago"
elif age.total_seconds() < 86400:
age_str = f"{int(age.total_seconds() / 3600)}h ago"
else:
age_str = mtime.strftime("%Y-%m-%d")
print(f" {entry.name:<25} {size_str:>8} {age_str}")
found = True
if not found:
print(" (no log files yet — run 'hermes chat' to generate logs)")
+918 -421
View File
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
"""hermes memory setup|status — configure memory provider plugins.
Auto-detects installed memory providers via the plugin system.
Interactive curses-based UI for provider selection, then walks through
the provider's config schema. Writes config to config.yaml + .env.
"""
from __future__ import annotations
import getpass
import os
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Curses-based interactive picker (same pattern as hermes tools)
# ---------------------------------------------------------------------------
def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int:
"""Interactive single-select with arrow keys.
items: list of (label, description) tuples.
Returns selected index, or default on escape/quit.
"""
try:
import curses
result = [default]
def _menu(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
cursor = default
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
# Title
try:
stdscr.addnstr(0, 0, title, max_x - 1,
curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0))
stdscr.addnstr(1, 0, " ↑↓ navigate ⏎ select q quit", max_x - 1,
curses.color_pair(3) if curses.has_colors() else curses.A_DIM)
except curses.error:
pass
for i, (label, desc) in enumerate(items):
y = i + 3
if y >= max_y - 1:
break
arrow = "" if i == cursor else " "
line = f" {arrow} {label}"
if desc:
line += f" {desc}"
attr = curses.A_NORMAL
if i == cursor:
attr = curses.A_BOLD
if curses.has_colors():
attr |= curses.color_pair(1)
try:
stdscr.addnstr(y, 0, line[:max_x - 1], max_x - 1, attr)
except curses.error:
pass
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord('k')):
cursor = (cursor - 1) % len(items)
elif key in (curses.KEY_DOWN, ord('j')):
cursor = (cursor + 1) % len(items)
elif key in (curses.KEY_ENTER, 10, 13):
result[0] = cursor
return
elif key in (27, ord('q')):
return
curses.wrapper(_menu)
return result[0]
except Exception:
# Fallback: numbered input
print(f"\n {title}\n")
for i, (label, desc) in enumerate(items):
marker = "" if i == default else " "
d = f" {desc}" if desc else ""
print(f" {marker} {i + 1}. {label}{d}")
while True:
try:
val = input(f"\n Select [1-{len(items)}] ({default + 1}): ")
if not val:
return default
idx = int(val) - 1
if 0 <= idx < len(items):
return idx
except (ValueError, EOFError):
return default
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
"""Prompt for a value with optional default and secret masking."""
suffix = f" [{default}]" if default else ""
if secret:
sys.stdout.write(f" {label}{suffix}: ")
sys.stdout.flush()
if sys.stdin.isatty():
val = getpass.getpass(prompt="")
else:
val = sys.stdin.readline().strip()
else:
sys.stdout.write(f" {label}{suffix}: ")
sys.stdout.flush()
val = sys.stdin.readline().strip()
return val or (default or "")
# ---------------------------------------------------------------------------
# Provider discovery
# ---------------------------------------------------------------------------
def _install_dependencies(provider_name: str) -> None:
"""Install pip dependencies declared in plugin.yaml."""
import subprocess
from pathlib import Path as _Path
plugin_dir = _Path(__file__).parent.parent / "plugins" / "memory" / provider_name
yaml_path = plugin_dir / "plugin.yaml"
if not yaml_path.exists():
return
try:
import yaml
with open(yaml_path) as f:
meta = yaml.safe_load(f) or {}
except Exception:
return
pip_deps = meta.get("pip_dependencies", [])
if not pip_deps:
return
# pip name → import name mapping for packages where they differ
_IMPORT_NAMES = {
"honcho-ai": "honcho",
"mem0ai": "mem0",
"hindsight-client": "hindsight_client",
"hindsight-all": "hindsight",
}
# Check which packages are missing
missing = []
for dep in pip_deps:
import_name = _IMPORT_NAMES.get(dep, dep.replace("-", "_").split("[")[0])
try:
__import__(import_name)
except ImportError:
missing.append(dep)
if not missing:
return
print(f"\n Installing dependencies: {', '.join(missing)}")
import shutil
uv_path = shutil.which("uv")
if not uv_path:
print(f" ⚠ uv not found — cannot install dependencies")
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
print(f" Then re-run: hermes memory setup")
return
try:
subprocess.run(
[uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing,
check=True, timeout=120,
capture_output=True,
)
print(f" ✓ Installed {', '.join(missing)}")
except subprocess.CalledProcessError as e:
print(f" ⚠ Failed to install {', '.join(missing)}")
stderr = (e.stderr or b"").decode()[:200]
if stderr:
print(f" {stderr}")
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
except Exception as e:
print(f" ⚠ Install failed: {e}")
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
# Also show external dependencies (non-pip) if any
ext_deps = meta.get("external_dependencies", [])
for dep in ext_deps:
dep_name = dep.get("name", "")
check_cmd = dep.get("check", "")
install_cmd = dep.get("install", "")
if check_cmd:
try:
subprocess.run(
check_cmd, shell=True, capture_output=True, timeout=5
)
except Exception:
if install_cmd:
print(f"\n'{dep_name}' not found. Install with:")
print(f" {install_cmd}")
def _get_available_providers() -> list:
"""Discover memory providers from plugins/memory/.
Returns list of (name, description, provider_instance) tuples.
"""
try:
from plugins.memory import discover_memory_providers, load_memory_provider
raw = discover_memory_providers()
except Exception:
raw = []
results = []
for name, desc, available in raw:
try:
provider = load_memory_provider(name)
if not provider:
continue
except Exception:
continue
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
has_secrets = any(f.get("secret") for f in schema)
has_non_secrets = any(not f.get("secret") for f in schema)
if has_secrets and has_non_secrets:
setup_hint = "API key / local"
elif has_secrets:
setup_hint = "requires API key"
elif not schema:
setup_hint = "no setup needed"
else:
setup_hint = "local"
results.append((name, setup_hint, provider))
return results
# ---------------------------------------------------------------------------
# Setup wizard
# ---------------------------------------------------------------------------
def cmd_setup_provider(provider_name: str) -> None:
"""Run memory setup for a specific provider, skipping the picker."""
from hermes_cli.config import load_config, save_config
providers = _get_available_providers()
match = None
for name, desc, provider in providers:
if name == provider_name:
match = (name, desc, provider)
break
if not match:
print(f"\n Memory provider '{provider_name}' not found.")
print(" Run 'hermes memory setup' to see available providers.\n")
return
name, _, provider = match
_install_dependencies(name)
config = load_config()
if not isinstance(config.get("memory"), dict):
config["memory"] = {}
if hasattr(provider, "post_setup"):
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
provider.post_setup(hermes_home, config)
return
# Fallback: generic schema-based setup (same as cmd_setup)
config["memory"]["provider"] = name
save_config(config)
print(f"\n Memory provider: {name}")
print(f" Activation saved to config.yaml\n")
def cmd_setup(args) -> None:
"""Interactive memory provider setup wizard."""
from hermes_cli.config import load_config, save_config
providers = _get_available_providers()
if not providers:
print("\n No memory provider plugins detected.")
print(" Install a plugin to ~/.hermes/plugins/ and try again.\n")
return
# Build picker items
items = []
for name, desc, _ in providers:
items.append((name, f"{desc}"))
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
builtin_idx = len(items) - 1
selected = _curses_select("Memory provider setup", items, default=builtin_idx)
config = load_config()
if not isinstance(config.get("memory"), dict):
config["memory"] = {}
# Built-in only
if selected >= len(providers) or selected < 0:
config["memory"]["provider"] = ""
save_config(config)
print("\n ✓ Memory provider: built-in only")
print(" Saved to config.yaml\n")
return
name, _, provider = providers[selected]
# Install pip dependencies if declared in plugin.yaml
_install_dependencies(name)
# If the provider has a post_setup hook, delegate entirely to it.
# The hook handles its own config, connection test, and activation.
if hasattr(provider, "post_setup"):
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
provider.post_setup(hermes_home, config)
return
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
provider_config = config["memory"].get(name, {})
if not isinstance(provider_config, dict):
provider_config = {}
env_path = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / ".env"
env_writes = {}
if schema:
print(f"\n Configuring {name}:\n")
for field in schema:
key = field["key"]
desc = field.get("description", key)
default = field.get("default")
# Dynamic default: look up default from another field's value
default_from = field.get("default_from")
if default_from and isinstance(default_from, dict):
ref_field = default_from.get("field", "")
ref_map = default_from.get("map", {})
ref_value = provider_config.get(ref_field, "")
if ref_value and ref_value in ref_map:
default = ref_map[ref_value]
is_secret = field.get("secret", False)
choices = field.get("choices")
env_var = field.get("env_var")
url = field.get("url")
# Skip fields whose "when" condition doesn't match
when = field.get("when")
if when and isinstance(when, dict):
if not all(provider_config.get(k) == v for k, v in when.items()):
continue
if choices and not is_secret:
# Use curses picker for choice fields
choice_items = [(c, "") for c in choices]
current = provider_config.get(key, default)
current_idx = 0
if current and current in choices:
current_idx = choices.index(current)
sel = _curses_select(f" {desc}", choice_items, default=current_idx)
provider_config[key] = choices[sel]
elif is_secret:
# Prompt for secret
existing = os.environ.get(env_var, "") if env_var else ""
if existing:
masked = f"...{existing[-4:]}" if len(existing) > 4 else "set"
val = _prompt(f"{desc} (current: {masked}, blank to keep)", secret=True)
else:
hint = f" Get yours at {url}" if url else ""
if hint:
print(hint)
val = _prompt(desc, secret=True)
if val and env_var:
env_writes[env_var] = val
else:
# Regular text prompt
current = provider_config.get(key)
effective_default = current or default
val = _prompt(desc, default=str(effective_default) if effective_default else None)
if val:
provider_config[key] = val
# Write activation key to config.yaml
config["memory"]["provider"] = name
save_config(config)
# Write non-secret config to provider's native location
hermes_home = str(Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))))
if provider_config and hasattr(provider, "save_config"):
try:
provider.save_config(provider_config, hermes_home)
except Exception as e:
print(f" Failed to write provider config: {e}")
# Write secrets to .env
if env_writes:
_write_env_vars(env_path, env_writes)
print(f"\n Memory provider: {name}")
print(f" Activation saved to config.yaml")
if provider_config:
print(f" Provider config saved")
if env_writes:
print(f" API keys saved to .env")
print(f"\n Start a new session to activate.\n")
def _write_env_vars(env_path: Path, env_writes: dict) -> None:
"""Append or update env vars in .env file."""
env_path.parent.mkdir(parents=True, exist_ok=True)
existing_lines = []
if env_path.exists():
existing_lines = env_path.read_text().splitlines()
updated_keys = set()
new_lines = []
for line in existing_lines:
key_match = line.split("=", 1)[0].strip() if "=" in line else ""
if key_match in env_writes:
new_lines.append(f"{key_match}={env_writes[key_match]}")
updated_keys.add(key_match)
else:
new_lines.append(line)
for key, val in env_writes.items():
if key not in updated_keys:
new_lines.append(f"{key}={val}")
env_path.write_text("\n".join(new_lines) + "\n")
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
def cmd_status(args) -> None:
"""Show current memory provider config."""
from hermes_cli.config import load_config
config = load_config()
mem_config = config.get("memory", {})
provider_name = mem_config.get("provider", "")
print(f"\nMemory status\n" + "" * 40)
print(f" Built-in: always active")
print(f" Provider: {provider_name or '(none — built-in only)'}")
if provider_name:
provider_config = mem_config.get(provider_name, {})
if provider_config:
print(f"\n {provider_name} config:")
for key, val in provider_config.items():
print(f" {key}: {val}")
providers = _get_available_providers()
found = any(name == provider_name for name, _, _ in providers)
if found:
print(f"\n Plugin: installed ✓")
for pname, _, p in providers:
if pname == provider_name:
if p.is_available():
print(f" Status: available ✓")
else:
print(f" Status: not available ✗")
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
secrets = [f for f in schema if f.get("secret")]
if secrets:
print(f" Missing:")
for s in secrets:
env_var = s.get("env_var", "")
url = s.get("url", "")
is_set = bool(os.environ.get(env_var))
mark = "" if is_set else ""
line = f" {mark} {env_var}"
if url and not is_set:
line += f"{url}"
print(line)
break
else:
print(f"\n Plugin: NOT installed ✗")
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
providers = _get_available_providers()
if providers:
print(f"\n Installed plugins:")
for pname, desc, _ in providers:
active = " ← active" if pname == provider_name else ""
print(f"{pname} ({desc}){active}")
print()
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def memory_command(args) -> None:
"""Route memory subcommands."""
sub = getattr(args, "memory_command", None)
if sub == "setup":
cmd_setup(args)
elif sub == "status":
cmd_status(args)
else:
cmd_status(args)
+361
View File
@@ -0,0 +1,361 @@
"""Per-provider model name normalization.
Different LLM providers expect model identifiers in different formats:
- **Aggregators** (OpenRouter, Nous, AI Gateway, Kilo Code) need
``vendor/model`` slugs like ``anthropic/claude-sonnet-4.6``.
- **Anthropic** native API expects bare names with dots replaced by
hyphens: ``claude-sonnet-4-6``.
- **Copilot** expects bare names *with* dots preserved:
``claude-sonnet-4.6``.
- **OpenCode Zen** follows the same dot-to-hyphen convention as
Anthropic: ``claude-sonnet-4-6``.
- **OpenCode Go** preserves dots in model names: ``minimax-m2.7``.
- **DeepSeek** only accepts two model identifiers:
``deepseek-chat`` and ``deepseek-reasoner``.
- **Custom** and remaining providers pass the name through as-is.
This module centralises that translation so callers can simply write::
api_model = normalize_model_for_provider(user_input, provider)
Inspired by Clawdbot's ``normalizeAnthropicModelId`` pattern.
"""
from __future__ import annotations
from typing import Optional
# ---------------------------------------------------------------------------
# Vendor prefix mapping
# ---------------------------------------------------------------------------
# Maps the first hyphen-delimited token of a bare model name to the vendor
# slug used by aggregator APIs (OpenRouter, Nous, etc.).
#
# Example: "claude-sonnet-4.6" -> first token "claude" -> vendor "anthropic"
# -> aggregator slug: "anthropic/claude-sonnet-4.6"
_VENDOR_PREFIXES: dict[str, str] = {
"claude": "anthropic",
"gpt": "openai",
"o1": "openai",
"o3": "openai",
"o4": "openai",
"gemini": "google",
"gemma": "google",
"deepseek": "deepseek",
"glm": "z-ai",
"kimi": "moonshotai",
"minimax": "minimax",
"grok": "x-ai",
"qwen": "qwen",
"mimo": "xiaomi",
"nemotron": "nvidia",
"llama": "meta-llama",
"step": "stepfun",
"trinity": "arcee-ai",
}
# Providers whose APIs consume vendor/model slugs.
_AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({
"openrouter",
"nous",
"ai-gateway",
"kilocode",
})
# Providers that want bare names with dots replaced by hyphens.
_DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({
"anthropic",
"opencode-zen",
})
# Providers that want bare names with dots preserved.
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
"copilot",
"copilot-acp",
})
# Providers whose own naming is authoritative -- pass through unchanged.
_PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({
"gemini",
"zai",
"kimi-coding",
"minimax",
"minimax-cn",
"alibaba",
"huggingface",
"openai-codex",
"custom",
})
# ---------------------------------------------------------------------------
# DeepSeek special handling
# ---------------------------------------------------------------------------
# DeepSeek's API only recognises exactly two model identifiers. We map
# common aliases and patterns to the canonical names.
_DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({
"reasoner",
"r1",
"think",
"reasoning",
"cot",
})
_DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({
"deepseek-chat",
"deepseek-reasoner",
})
def _normalize_for_deepseek(model_name: str) -> str:
"""Map any model input to one of DeepSeek's two accepted identifiers.
Rules:
- Already ``deepseek-chat`` or ``deepseek-reasoner`` -> pass through.
- Contains any reasoner keyword (r1, think, reasoning, cot, reasoner)
-> ``deepseek-reasoner``.
- Everything else -> ``deepseek-chat``.
Args:
model_name: The bare model name (vendor prefix already stripped).
Returns:
One of ``"deepseek-chat"`` or ``"deepseek-reasoner"``.
"""
bare = _strip_vendor_prefix(model_name).lower()
if bare in _DEEPSEEK_CANONICAL_MODELS:
return bare
# Check for reasoner-like keywords anywhere in the name
for keyword in _DEEPSEEK_REASONER_KEYWORDS:
if keyword in bare:
return "deepseek-reasoner"
return "deepseek-chat"
# ---------------------------------------------------------------------------
# Helper utilities
# ---------------------------------------------------------------------------
def _strip_vendor_prefix(model_name: str) -> str:
"""Remove a ``vendor/`` prefix if present.
Examples::
>>> _strip_vendor_prefix("anthropic/claude-sonnet-4.6")
'claude-sonnet-4.6'
>>> _strip_vendor_prefix("claude-sonnet-4.6")
'claude-sonnet-4.6'
>>> _strip_vendor_prefix("meta-llama/llama-4-scout")
'llama-4-scout'
"""
if "/" in model_name:
return model_name.split("/", 1)[1]
return model_name
def _dots_to_hyphens(model_name: str) -> str:
"""Replace dots with hyphens in a model name.
Anthropic's native API uses hyphens where marketing names use dots:
``claude-sonnet-4.6`` -> ``claude-sonnet-4-6``.
"""
return model_name.replace(".", "-")
def detect_vendor(model_name: str) -> Optional[str]:
"""Detect the vendor slug from a bare model name.
Uses the first hyphen-delimited token of the model name to look up
the corresponding vendor in ``_VENDOR_PREFIXES``. Also handles
case-insensitive matching and special patterns.
Args:
model_name: A model name, optionally already including a
``vendor/`` prefix. If a prefix is present it is used
directly.
Returns:
The vendor slug (e.g. ``"anthropic"``, ``"openai"``) or ``None``
if no vendor can be confidently detected.
Examples::
>>> detect_vendor("claude-sonnet-4.6")
'anthropic'
>>> detect_vendor("gpt-5.4-mini")
'openai'
>>> detect_vendor("anthropic/claude-sonnet-4.6")
'anthropic'
>>> detect_vendor("my-custom-model")
"""
name = model_name.strip()
if not name:
return None
# If there's already a vendor/ prefix, extract it
if "/" in name:
return name.split("/", 1)[0].lower() or None
name_lower = name.lower()
# Try first hyphen-delimited token (exact match)
first_token = name_lower.split("-")[0]
if first_token in _VENDOR_PREFIXES:
return _VENDOR_PREFIXES[first_token]
# Handle patterns where the first token includes version digits,
# e.g. "qwen3.5-plus" -> first token "qwen3.5", but prefix is "qwen"
for prefix, vendor in _VENDOR_PREFIXES.items():
if name_lower.startswith(prefix):
return vendor
return None
def _prepend_vendor(model_name: str) -> str:
"""Prepend the detected ``vendor/`` prefix if missing.
Used for aggregator providers that require ``vendor/model`` format.
If the name already contains a ``/``, it is returned as-is.
If no vendor can be detected, the name is returned unchanged
(aggregators may still accept it or return an error).
Examples::
>>> _prepend_vendor("claude-sonnet-4.6")
'anthropic/claude-sonnet-4.6'
>>> _prepend_vendor("anthropic/claude-sonnet-4.6")
'anthropic/claude-sonnet-4.6'
>>> _prepend_vendor("my-custom-thing")
'my-custom-thing'
"""
if "/" in model_name:
return model_name
vendor = detect_vendor(model_name)
if vendor:
return f"{vendor}/{model_name}"
return model_name
# ---------------------------------------------------------------------------
# Main normalisation entry point
# ---------------------------------------------------------------------------
def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
"""Translate a model name into the format the target provider's API expects.
This is the primary entry point for model name normalisation. It
accepts any user-facing model identifier and transforms it for the
specific provider that will receive the API call.
Args:
model_input: The model name as provided by the user or config.
Can be bare (``"claude-sonnet-4.6"``), vendor-prefixed
(``"anthropic/claude-sonnet-4.6"``), or already in native
format (``"claude-sonnet-4-6"``).
target_provider: The canonical Hermes provider id, e.g.
``"openrouter"``, ``"anthropic"``, ``"copilot"``,
``"deepseek"``, ``"custom"``. Should already be normalised
via ``hermes_cli.models.normalize_provider()``.
Returns:
The model identifier string that the target provider's API
expects.
Raises:
No exceptions -- always returns a best-effort string.
Examples::
>>> normalize_model_for_provider("claude-sonnet-4.6", "openrouter")
'anthropic/claude-sonnet-4.6'
>>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic")
'claude-sonnet-4-6'
>>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "copilot")
'claude-sonnet-4.6'
>>> normalize_model_for_provider("openai/gpt-5.4", "copilot")
'gpt-5.4'
>>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen")
'claude-sonnet-4-6'
>>> normalize_model_for_provider("deepseek-v3", "deepseek")
'deepseek-chat'
>>> normalize_model_for_provider("deepseek-r1", "deepseek")
'deepseek-reasoner'
>>> normalize_model_for_provider("my-model", "custom")
'my-model'
>>> normalize_model_for_provider("claude-sonnet-4.6", "zai")
'claude-sonnet-4.6'
"""
name = (model_input or "").strip()
if not name:
return name
provider = (target_provider or "").strip().lower()
# --- Aggregators: need vendor/model format ---
if provider in _AGGREGATOR_PROVIDERS:
return _prepend_vendor(name)
# --- Anthropic / OpenCode: strip vendor, dots -> hyphens ---
if provider in _DOT_TO_HYPHEN_PROVIDERS:
bare = _strip_vendor_prefix(name)
return _dots_to_hyphens(bare)
# --- Copilot: strip vendor, keep dots ---
if provider in _STRIP_VENDOR_ONLY_PROVIDERS:
return _strip_vendor_prefix(name)
# --- DeepSeek: map to one of two canonical names ---
if provider == "deepseek":
return _normalize_for_deepseek(name)
# --- Custom & all others: pass through as-is ---
return name
# ---------------------------------------------------------------------------
# Batch / convenience helpers
# ---------------------------------------------------------------------------
def model_display_name(model_id: str) -> str:
"""Return a short, human-readable display name for a model id.
Strips the vendor prefix (if any) for a cleaner display in menus
and status bars, while preserving dots for readability.
Examples::
>>> model_display_name("anthropic/claude-sonnet-4.6")
'claude-sonnet-4.6'
>>> model_display_name("claude-sonnet-4-6")
'claude-sonnet-4-6'
"""
return _strip_vendor_prefix((model_id or "").strip())
def is_aggregator_provider(provider: str) -> bool:
"""Check if a provider is an aggregator that needs vendor/model format."""
return (provider or "").strip().lower() in _AGGREGATOR_PROVIDERS
def vendor_for_model(model_name: str) -> str:
"""Return the vendor slug for a model, or ``""`` if unknown.
Convenience wrapper around :func:`detect_vendor` that never returns
``None``.
"""
return detect_vendor(model_name) or ""
+759 -64
View File
@@ -3,18 +3,204 @@
Both the CLI (cli.py) and gateway (gateway/run.py) /model handlers
share the same core pipeline:
parse_model_input is_custom detection auto-detect provider
credential resolution validate model return result
parse flags -> alias resolution -> provider resolution ->
credential resolution -> normalize model name ->
metadata lookup -> build result
This module extracts that shared pipeline into pure functions that
return result objects. The callers handle all platform-specific
concerns: state mutation, config persistence, output formatting.
This module ties together the foundation layers:
- ``agent.models_dev`` -- models.dev catalog, ModelInfo, ProviderInfo
- ``hermes_cli.providers`` -- canonical provider identity + overlays
- ``hermes_cli.model_normalize`` -- per-provider name formatting
Provider switching uses the ``--provider`` flag exclusively.
No colon-based ``provider:model`` syntax colons are reserved for
OpenRouter variant suffixes (``:free``, ``:extended``, ``:fast``).
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from dataclasses import dataclass, field
from typing import List, NamedTuple, Optional
from hermes_cli.providers import (
ALIASES,
LABELS,
TRANSPORT_TO_API_MODE,
determine_api_mode,
get_label,
get_provider,
is_aggregator,
normalize_provider,
resolve_provider_full,
)
from hermes_cli.model_normalize import (
detect_vendor,
normalize_model_for_provider,
)
from agent.models_dev import (
ModelCapabilities,
ModelInfo,
get_model_capabilities,
get_model_info,
list_provider_models,
search_models_dev,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Non-agentic model warning
# ---------------------------------------------------------------------------
_HERMES_MODEL_WARNING = (
"Nous Research Hermes 3 & 4 models are NOT agentic and are not designed "
"for use with Hermes Agent. They lack the tool-calling capabilities "
"required for agent workflows. Consider using an agentic model instead "
"(Claude, GPT, Gemini, DeepSeek, etc.)."
)
def _check_hermes_model_warning(model_name: str) -> str:
"""Return a warning string if *model_name* looks like a Hermes LLM model."""
if "hermes" in model_name.lower():
return _HERMES_MODEL_WARNING
return ""
# ---------------------------------------------------------------------------
# Model aliases -- short names -> (vendor, family) with NO version numbers.
# Resolved dynamically against the live models.dev catalog.
# ---------------------------------------------------------------------------
class ModelIdentity(NamedTuple):
"""Vendor slug and family prefix used for catalog resolution."""
vendor: str
family: str
MODEL_ALIASES: dict[str, ModelIdentity] = {
# Anthropic
"sonnet": ModelIdentity("anthropic", "claude-sonnet"),
"opus": ModelIdentity("anthropic", "claude-opus"),
"haiku": ModelIdentity("anthropic", "claude-haiku"),
"claude": ModelIdentity("anthropic", "claude"),
# OpenAI
"gpt5": ModelIdentity("openai", "gpt-5"),
"gpt": ModelIdentity("openai", "gpt"),
"codex": ModelIdentity("openai", "codex"),
"o3": ModelIdentity("openai", "o3"),
"o4": ModelIdentity("openai", "o4"),
# Google
"gemini": ModelIdentity("google", "gemini"),
# DeepSeek
"deepseek": ModelIdentity("deepseek", "deepseek-chat"),
# X.AI
"grok": ModelIdentity("x-ai", "grok"),
# Meta
"llama": ModelIdentity("meta-llama", "llama"),
# Qwen / Alibaba
"qwen": ModelIdentity("qwen", "qwen"),
# MiniMax
"minimax": ModelIdentity("minimax", "minimax"),
# Nvidia
"nemotron": ModelIdentity("nvidia", "nemotron"),
# Moonshot / Kimi
"kimi": ModelIdentity("moonshotai", "kimi"),
# Z.AI / GLM
"glm": ModelIdentity("z-ai", "glm"),
# StepFun
"step": ModelIdentity("stepfun", "step"),
# Xiaomi
"mimo": ModelIdentity("xiaomi", "mimo"),
# Arcee
"trinity": ModelIdentity("arcee-ai", "trinity"),
}
# ---------------------------------------------------------------------------
# Direct aliases — exact model+provider+base_url for endpoints that aren't
# in the models.dev catalog (e.g. Ollama Cloud, local servers).
# Checked BEFORE catalog resolution. Format:
# alias -> (model_id, provider, base_url)
# These can also be loaded from config.yaml ``model_aliases:`` section.
# ---------------------------------------------------------------------------
class DirectAlias(NamedTuple):
"""Exact model mapping that bypasses catalog resolution."""
model: str
provider: str
base_url: str
# Built-in direct aliases (can be extended via config.yaml model_aliases:)
_BUILTIN_DIRECT_ALIASES: dict[str, DirectAlias] = {}
# Merged dict (builtins + user config); populated by _load_direct_aliases()
DIRECT_ALIASES: dict[str, DirectAlias] = {}
def _load_direct_aliases() -> dict[str, DirectAlias]:
"""Load direct aliases from config.yaml ``model_aliases:`` section.
Config format::
model_aliases:
qwen:
model: "qwen3.5:397b"
provider: custom
base_url: "https://ollama.com/v1"
minimax:
model: "minimax-m2.7"
provider: custom
base_url: "https://ollama.com/v1"
"""
merged = dict(_BUILTIN_DIRECT_ALIASES)
try:
from hermes_cli.config import load_config
cfg = load_config()
user_aliases = cfg.get("model_aliases")
if isinstance(user_aliases, dict):
for name, entry in user_aliases.items():
if not isinstance(entry, dict):
continue
model = entry.get("model", "")
provider = entry.get("provider", "custom")
base_url = entry.get("base_url", "")
if model:
merged[name.strip().lower()] = DirectAlias(
model=model, provider=provider, base_url=base_url,
)
except Exception:
pass
return merged
def _ensure_direct_aliases() -> None:
"""Lazy-load direct aliases on first use."""
global DIRECT_ALIASES
if not DIRECT_ALIASES:
DIRECT_ALIASES = _load_direct_aliases()
# ---------------------------------------------------------------------------
# Result dataclasses
# ---------------------------------------------------------------------------
@dataclass
class ModelSwitchResult:
@@ -26,11 +212,14 @@ class ModelSwitchResult:
provider_changed: bool = False
api_key: str = ""
base_url: str = ""
persist: bool = False
api_mode: str = ""
error_message: str = ""
warning_message: str = ""
is_custom_target: bool = False
provider_label: str = ""
resolved_via_alias: str = ""
capabilities: Optional[ModelCapabilities] = None
model_info: Optional[ModelInfo] = None
is_global: bool = False
@dataclass
@@ -44,96 +233,411 @@ class CustomAutoResult:
error_message: str = ""
# ---------------------------------------------------------------------------
# Flag parsing
# ---------------------------------------------------------------------------
def parse_model_flags(raw_args: str) -> tuple[str, str, bool]:
"""Parse --provider and --global flags from /model command args.
Returns (model_input, explicit_provider, is_global).
Examples::
"sonnet" -> ("sonnet", "", False)
"sonnet --global" -> ("sonnet", "", True)
"sonnet --provider anthropic" -> ("sonnet", "anthropic", False)
"--provider my-ollama" -> ("", "my-ollama", False)
"sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True)
"""
is_global = False
explicit_provider = ""
# Extract --global
if "--global" in raw_args:
is_global = True
raw_args = raw_args.replace("--global", "").strip()
# Extract --provider <name>
parts = raw_args.split()
i = 0
filtered: list[str] = []
while i < len(parts):
if parts[i] == "--provider" and i + 1 < len(parts):
explicit_provider = parts[i + 1]
i += 2
else:
filtered.append(parts[i])
i += 1
model_input = " ".join(filtered).strip()
return (model_input, explicit_provider, is_global)
# ---------------------------------------------------------------------------
# Alias resolution
# ---------------------------------------------------------------------------
def resolve_alias(
raw_input: str,
current_provider: str,
) -> Optional[tuple[str, str, str]]:
"""Resolve a short alias against the current provider's catalog.
Looks up *raw_input* in :data:`MODEL_ALIASES`, then searches the
current provider's models.dev catalog for the first model whose ID
starts with ``vendor/family`` (or just ``family`` for non-aggregator
providers).
Returns:
``(provider, resolved_model_id, alias_name)`` if a match is
found on the current provider, or ``None`` if the alias doesn't
exist or no matching model is available.
"""
key = raw_input.strip().lower()
# Check direct aliases first (exact model+provider+base_url mappings)
_ensure_direct_aliases()
direct = DIRECT_ALIASES.get(key)
if direct is not None:
return (direct.provider, direct.model, key)
# Reverse lookup: match by model ID so full names (e.g. "kimi-k2.5",
# "glm-4.7") route through direct aliases instead of falling through
# to the catalog/OpenRouter.
for alias_name, da in DIRECT_ALIASES.items():
if da.model.lower() == key:
return (da.provider, da.model, alias_name)
identity = MODEL_ALIASES.get(key)
if identity is None:
return None
vendor, family = identity
# Search the provider's catalog from models.dev
catalog = list_provider_models(current_provider)
if not catalog:
return None
# For aggregators, models are vendor/model-name format
aggregator = is_aggregator(current_provider)
for model_id in catalog:
mid_lower = model_id.lower()
if aggregator:
# Match vendor/family prefix -- e.g. "anthropic/claude-sonnet"
prefix = f"{vendor}/{family}".lower()
if mid_lower.startswith(prefix):
return (current_provider, model_id, key)
else:
# Non-aggregator: bare names -- e.g. "claude-sonnet-4-6"
family_lower = family.lower()
if mid_lower.startswith(family_lower):
return (current_provider, model_id, key)
return None
def get_authenticated_provider_slugs(
current_provider: str = "",
user_providers: dict = None,
) -> list[str]:
"""Return slugs of providers that have credentials.
Uses ``list_authenticated_providers()`` which is backed by the models.dev
in-memory cache (1 hr TTL) no extra network cost.
"""
try:
providers = list_authenticated_providers(
current_provider=current_provider,
user_providers=user_providers,
max_models=0,
)
return [p["slug"] for p in providers]
except Exception:
return []
def _resolve_alias_fallback(
raw_input: str,
authenticated_providers: list[str] = (),
) -> Optional[tuple[str, str, str]]:
"""Try to resolve an alias on the user's authenticated providers.
Falls back to ``("openrouter", "nous")`` only when no authenticated
providers are supplied (backwards compat for non-interactive callers).
"""
providers = authenticated_providers or ("openrouter", "nous")
for provider in providers:
result = resolve_alias(raw_input, provider)
if result is not None:
return result
return None
# ---------------------------------------------------------------------------
# Core model-switching pipeline
# ---------------------------------------------------------------------------
def switch_model(
raw_input: str,
current_provider: str,
current_model: str,
current_base_url: str = "",
current_api_key: str = "",
is_global: bool = False,
explicit_provider: str = "",
user_providers: dict = None,
) -> ModelSwitchResult:
"""Core model-switching pipeline shared between CLI and gateway.
Handles parsing, provider detection, credential resolution, and
model validation. Does NOT handle config persistence, state
mutation, or output formatting those are caller responsibilities.
Resolution chain:
If --provider given:
a. Resolve provider via resolve_provider_full()
b. Resolve credentials
c. If model given, resolve alias on target provider or use as-is
d. If no model, auto-detect from endpoint
If no --provider:
a. Try alias resolution on current provider
b. If alias exists but not on current provider -> fallback
c. On aggregator, try vendor/model slug conversion
d. Aggregator catalog search
e. detect_provider_for_model() as last resort
f. Resolve credentials
g. Normalize model name for target provider
Finally:
h. Get full model metadata from models.dev
i. Build result
Args:
raw_input: The user's model input (e.g. "claude-sonnet-4",
"zai:glm-5", "custom:local:qwen").
raw_input: The model name (after flag parsing).
current_provider: The currently active provider.
current_base_url: The currently active base URL (used for
is_custom detection).
current_model: The currently active model name.
current_base_url: The currently active base URL.
current_api_key: The currently active API key.
is_global: Whether to persist the switch.
explicit_provider: From --provider flag (empty = no explicit provider).
user_providers: The ``providers:`` dict from config.yaml (for user endpoints).
Returns:
ModelSwitchResult with all information the caller needs to
apply the switch and format output.
ModelSwitchResult with all information the caller needs.
"""
from hermes_cli.models import (
parse_model_input,
detect_provider_for_model,
validate_requested_model,
_PROVIDER_LABELS,
opencode_model_api_mode,
)
from hermes_cli.runtime_provider import resolve_runtime_provider
# Step 1: Parse provider:model syntax
target_provider, new_model = parse_model_input(raw_input, current_provider)
resolved_alias = ""
new_model = raw_input.strip()
target_provider = current_provider
# Step 2: Detect if we're currently on a custom endpoint
_base = current_base_url or ""
is_custom = current_provider == "custom" or (
"localhost" in _base or "127.0.0.1" in _base
)
# =================================================================
# PATH A: Explicit --provider given
# =================================================================
if explicit_provider:
# Resolve the provider
pdef = resolve_provider_full(explicit_provider, user_providers)
if pdef is None:
_switch_err = (
f"Unknown provider '{explicit_provider}'. "
f"Check 'hermes model' for available providers, or define it "
f"in config.yaml under 'providers:'."
)
# Check for common config issues that cause provider resolution failures
try:
from hermes_cli.config import validate_config_structure
_cfg_issues = validate_config_structure()
if _cfg_issues:
_switch_err += "\n\nRun 'hermes doctor' — config issues detected:"
for _ci in _cfg_issues[:3]:
_switch_err += f"\n{_ci.message}"
except Exception:
pass
return ModelSwitchResult(
success=False,
is_global=is_global,
error_message=_switch_err,
)
# Step 3: Auto-detect provider when no explicit provider:model syntax
# was used. Skip for custom providers — the model name might
# coincidentally match a known provider's catalog.
if target_provider == current_provider and not is_custom:
detected = detect_provider_for_model(new_model, current_provider)
if detected:
target_provider, new_model = detected
target_provider = pdef.id
# If no model specified, try auto-detect from endpoint
if not new_model:
if pdef.base_url:
from hermes_cli.runtime_provider import _auto_detect_local_model
detected = _auto_detect_local_model(pdef.base_url)
if detected:
new_model = detected
else:
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=pdef.name,
is_global=is_global,
error_message=(
f"No model detected on {pdef.name} ({pdef.base_url}). "
f"Specify the model explicitly: /model <model-name> --provider {explicit_provider}"
),
)
else:
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=pdef.name,
is_global=is_global,
error_message=(
f"Provider '{pdef.name}' has no base URL configured. "
f"Specify a model: /model <model-name> --provider {explicit_provider}"
),
)
# Resolve alias on the TARGET provider
alias_result = resolve_alias(new_model, target_provider)
if alias_result is not None:
_, new_model, resolved_alias = alias_result
# =================================================================
# PATH B: No explicit provider — resolve from model input
# =================================================================
else:
# --- Step a: Try alias resolution on current provider ---
alias_result = resolve_alias(raw_input, current_provider)
if alias_result is not None:
target_provider, new_model, resolved_alias = alias_result
logger.debug(
"Alias '%s' resolved to %s on %s",
resolved_alias, new_model, target_provider,
)
else:
# --- Step b: Alias exists but not on current provider -> fallback ---
key = raw_input.strip().lower()
if key in MODEL_ALIASES:
authed = get_authenticated_provider_slugs(
current_provider=current_provider,
user_providers=user_providers,
)
fallback_result = _resolve_alias_fallback(raw_input, authed)
if fallback_result is not None:
target_provider, new_model, resolved_alias = fallback_result
logger.debug(
"Alias '%s' resolved via fallback to %s on %s",
resolved_alias, new_model, target_provider,
)
else:
identity = MODEL_ALIASES[key]
return ModelSwitchResult(
success=False,
is_global=is_global,
error_message=(
f"Alias '{key}' maps to {identity.vendor}/{identity.family} "
f"but no matching model was found in any provider catalog. "
f"Try specifying the full model name."
),
)
else:
# --- Step c: On aggregator, convert vendor:model to vendor/model ---
colon_pos = raw_input.find(":")
if colon_pos > 0 and is_aggregator(current_provider):
left = raw_input[:colon_pos].strip().lower()
right = raw_input[colon_pos + 1:].strip()
if left and right:
# Colons become slashes for aggregator slugs
new_model = f"{left}/{right}"
logger.debug(
"Converted vendor:model '%s' to aggregator slug '%s'",
raw_input, new_model,
)
# --- Step d: Aggregator catalog search ---
if is_aggregator(target_provider) and not resolved_alias:
catalog = list_provider_models(target_provider)
if catalog:
new_model_lower = new_model.lower()
for mid in catalog:
if mid.lower() == new_model_lower:
new_model = mid
break
else:
for mid in catalog:
if "/" in mid:
_, bare = mid.split("/", 1)
if bare.lower() == new_model_lower:
new_model = mid
break
# --- Step e: detect_provider_for_model() as last resort ---
_base = current_base_url or ""
is_custom = current_provider in ("custom", "local") or (
"localhost" in _base or "127.0.0.1" in _base
)
if (
target_provider == current_provider
and not is_custom
and not resolved_alias
):
detected = detect_provider_for_model(new_model, current_provider)
if detected:
target_provider, new_model = detected
# =================================================================
# COMMON PATH: Resolve credentials, normalize, get metadata
# =================================================================
provider_changed = target_provider != current_provider
provider_label = get_label(target_provider)
# Step 4: Resolve credentials for target provider
# --- Resolve credentials ---
api_key = current_api_key
base_url = current_base_url
if provider_changed:
api_mode = ""
if provider_changed or explicit_provider:
try:
runtime = resolve_runtime_provider(requested=target_provider)
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception as e:
provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
if target_provider == "custom":
return ModelSwitchResult(
success=False,
target_provider=target_provider,
error_message=(
"No custom endpoint configured. Set model.base_url "
"in config.yaml, or set OPENAI_BASE_URL in .env, "
"or run: hermes setup → Custom OpenAI-compatible endpoint"
),
)
return ModelSwitchResult(
success=False,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=(
f"Could not resolve credentials for provider "
f"'{provider_label}': {e}"
),
)
else:
# Gateway also resolves for unchanged provider to get accurate
# base_url for validation probing.
try:
runtime = resolve_runtime_provider(requested=current_provider)
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception:
pass
# Step 5: Validate the model
# --- Direct alias override: use exact base_url from the alias if set ---
if resolved_alias:
_ensure_direct_aliases()
_da = DIRECT_ALIASES.get(resolved_alias)
if _da is not None and _da.base_url:
base_url = _da.base_url
if not api_key:
api_key = "no-key-required"
# --- Normalize model name for target provider ---
new_model = normalize_model_for_provider(new_model, target_provider)
# --- Validate ---
try:
validation = validate_requested_model(
new_model,
@@ -155,17 +659,34 @@ def switch_model(
success=False,
new_model=new_model,
target_provider=target_provider,
provider_label=provider_label,
is_global=is_global,
error_message=msg,
)
# Step 6: Build result
provider_label = _PROVIDER_LABELS.get(target_provider, target_provider)
is_custom_target = target_provider == "custom" or (
base_url
and "openrouter.ai" not in (base_url or "")
and ("localhost" in (base_url or "") or "127.0.0.1" in (base_url or ""))
)
# --- OpenCode api_mode override ---
if target_provider in {"opencode-zen", "opencode-go", "opencode", "opencode-go"}:
api_mode = opencode_model_api_mode(target_provider, new_model)
# --- Determine api_mode if not already set ---
if not api_mode:
api_mode = determine_api_mode(target_provider, base_url)
# --- Get capabilities (legacy) ---
capabilities = get_model_capabilities(target_provider, new_model)
# --- Get full model info from models.dev ---
model_info = get_model_info(target_provider, new_model)
# --- Collect warnings ---
warnings: list[str] = []
if validation.get("message"):
warnings.append(validation["message"])
hermes_warn = _check_hermes_model_warning(new_model)
if hermes_warn:
warnings.append(hermes_warn)
# --- Build result ---
return ModelSwitchResult(
success=True,
new_model=new_model,
@@ -173,18 +694,192 @@ def switch_model(
provider_changed=provider_changed,
api_key=api_key,
base_url=base_url,
persist=bool(validation.get("persist")),
warning_message=validation.get("message") or "",
is_custom_target=is_custom_target,
api_mode=api_mode,
warning_message=" | ".join(warnings) if warnings else "",
provider_label=provider_label,
resolved_via_alias=resolved_alias,
capabilities=capabilities,
model_info=model_info,
is_global=is_global,
)
def switch_to_custom_provider() -> CustomAutoResult:
"""Handle bare '/model custom' — resolve endpoint and auto-detect model.
# ---------------------------------------------------------------------------
# Authenticated providers listing (for /model no-args display)
# ---------------------------------------------------------------------------
Returns a result object; the caller handles persistence and output.
def list_authenticated_providers(
current_provider: str = "",
user_providers: dict = None,
max_models: int = 8,
) -> List[dict]:
"""Detect which providers have credentials and list their curated models.
Uses the curated model lists from hermes_cli/models.py (OPENROUTER_MODELS,
_PROVIDER_MODELS) NOT the full models.dev catalog. These are hand-picked
agentic models that work well as agent backends.
Returns a list of dicts, each with:
- slug: str the --provider value to use
- name: str display name
- is_current: bool
- is_user_defined: bool
- models: list[str] curated model IDs (up to max_models)
- total_models: int total curated count
- source: str "built-in", "models.dev", "user-config"
Only includes providers that have API keys set or are user-defined endpoints.
"""
import os
from agent.models_dev import (
PROVIDER_TO_MODELS_DEV,
fetch_models_dev,
get_provider_info as _mdev_pinfo,
)
from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS
results: List[dict] = []
seen_slugs: set = set()
data = fetch_models_dev()
# Build curated model lists keyed by hermes provider ID
curated: dict[str, list[str]] = dict(_PROVIDER_MODELS)
curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS]
# "nous" shares OpenRouter's curated list if not separately defined
if "nous" not in curated:
curated["nous"] = curated["openrouter"]
# --- 1. Check Hermes-mapped providers ---
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
pdata = data.get(mdev_id)
if not isinstance(pdata, dict):
continue
env_vars = pdata.get("env", [])
if not isinstance(env_vars, list):
continue
# Check if any env var is set
has_creds = any(os.environ.get(ev) for ev in env_vars)
if not has_creds:
continue
# Use curated list, falling back to models.dev if no curated list
model_ids = curated.get(hermes_id, [])
total = len(model_ids)
top = model_ids[:max_models]
slug = hermes_id
pinfo = _mdev_pinfo(mdev_id)
display_name = pinfo.name if pinfo else mdev_id
results.append({
"slug": slug,
"name": display_name,
"is_current": slug == current_provider or mdev_id == current_provider,
"is_user_defined": False,
"models": top,
"total_models": total,
"source": "built-in",
})
seen_slugs.add(slug)
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot) ---
from hermes_cli.providers import HERMES_OVERLAYS
for pid, overlay in HERMES_OVERLAYS.items():
if pid in seen_slugs:
continue
# Check if credentials exist
has_creds = False
if overlay.extra_env_vars:
has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars)
if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
# These use auth stores, not env vars — check for auth.json entries
try:
from hermes_cli.auth import _read_auth_store
store = _read_auth_store()
if store and pid in store:
has_creds = True
except Exception:
pass
if not has_creds:
continue
# Use curated list
model_ids = curated.get(pid, [])
total = len(model_ids)
top = model_ids[:max_models]
results.append({
"slug": pid,
"name": get_label(pid),
"is_current": pid == current_provider,
"is_user_defined": False,
"models": top,
"total_models": total,
"source": "hermes",
})
seen_slugs.add(pid)
# --- 3. User-defined endpoints from config ---
if user_providers and isinstance(user_providers, dict):
for ep_name, ep_cfg in user_providers.items():
if not isinstance(ep_cfg, dict):
continue
display_name = ep_cfg.get("name", "") or ep_name
api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or ""
default_model = ep_cfg.get("default_model", "")
models_list = []
if default_model:
models_list.append(default_model)
# Try to probe /v1/models if URL is set (but don't block on it)
# For now just show what we know from config
results.append({
"slug": ep_name,
"name": display_name,
"is_current": ep_name == current_provider,
"is_user_defined": True,
"models": models_list,
"total_models": len(models_list) if models_list else 0,
"source": "user-config",
"api_url": api_url,
})
# Sort: current provider first, then by model count descending
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
return results
# ---------------------------------------------------------------------------
# Fuzzy suggestions
# ---------------------------------------------------------------------------
def suggest_models(raw_input: str, limit: int = 3) -> List[str]:
"""Return fuzzy model suggestions for a (possibly misspelled) input."""
query = raw_input.strip()
if not query:
return []
results = search_models_dev(query, limit=limit)
suggestions: list[str] = []
for r in results:
mid = r.get("model_id", "")
if mid:
suggestions.append(mid)
return suggestions[:limit]
# ---------------------------------------------------------------------------
# Custom provider switch
# ---------------------------------------------------------------------------
def switch_to_custom_provider() -> CustomAutoResult:
"""Handle bare '/model --provider custom' — resolve endpoint and auto-detect model."""
from hermes_cli.runtime_provider import (
resolve_runtime_provider,
_auto_detect_local_model,
@@ -207,7 +902,7 @@ def switch_to_custom_provider() -> CustomAutoResult:
error_message=(
"No custom endpoint configured. "
"Set model.base_url in config.yaml, or set OPENAI_BASE_URL "
"in .env, or run: hermes setup Custom OpenAI-compatible endpoint"
"in .env, or run: hermes setup -> Custom OpenAI-compatible endpoint"
),
)
@@ -220,7 +915,7 @@ def switch_to_custom_provider() -> CustomAutoResult:
error_message=(
f"Custom endpoint at {cust_base} is reachable but no single "
f"model was auto-detected. Specify the model explicitly: "
f"/model custom:<model-name>"
f"/model <model-name> --provider custom"
),
)
+283 -3
View File
@@ -28,7 +28,7 @@ GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL
OPENROUTER_MODELS: list[tuple[str, str]] = [
("anthropic/claude-opus-4.6", "recommended"),
("anthropic/claude-sonnet-4.6", ""),
("qwen/qwen3.6-plus-preview:free", "free"),
("qwen/qwen3.6-plus:free", "free"),
("anthropic/claude-sonnet-4.5", ""),
("anthropic/claude-haiku-4.5", ""),
("openai/gpt-5.4", ""),
@@ -51,6 +51,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("nvidia/nemotron-3-super-120b-a12b", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("arcee-ai/trinity-large-preview:free", "free"),
("arcee-ai/trinity-large-thinking", ""),
("openai/gpt-5.4-pro", ""),
("openai/gpt-5.4-nano", ""),
]
@@ -59,7 +60,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"qwen/qwen3.6-plus-preview:free",
"anthropic/claude-sonnet-4.5",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.4",
@@ -82,6 +82,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"nvidia/nemotron-3-super-120b-a12b",
"nvidia/nemotron-3-super-120b-a12b:free",
"arcee-ai/trinity-large-preview:free",
"arcee-ai/trinity-large-thinking",
"openai/gpt-5.4-pro",
"openai/gpt-5.4-nano",
],
@@ -110,6 +111,17 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini-2.5-pro",
"grok-code-fast-1",
],
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
# Gemma open models (also served via AI Studio)
"gemma-4-31b-it",
"gemma-4-26b-it",
],
"zai": [
"glm-5",
"glm-5-turbo",
@@ -125,6 +137,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"moonshot": [
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"minimax": [
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
@@ -193,7 +211,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"opencode-go": [
"glm-5",
"kimi-k2.5",
"mimo-v2-pro",
"mimo-v2-omni",
"minimax-m2.7",
"minimax-m2.5",
],
"ai-gateway": [
"anthropic/claude-opus-4.6",
@@ -250,6 +271,7 @@ _PROVIDER_LABELS = {
"copilot-acp": "GitHub Copilot ACP",
"nous": "Nous Portal",
"copilot": "GitHub Copilot",
"gemini": "Google AI Studio",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
@@ -276,6 +298,9 @@ _PROVIDER_ALIASES = {
"github-model": "copilot",
"github-copilot-acp": "copilot-acp",
"copilot-acp-agent": "copilot-acp",
"google": "gemini",
"google-gemini": "gemini",
"google-ai-studio": "gemini",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"minimax-china": "minimax-cn",
@@ -316,6 +341,213 @@ def menu_labels() -> list[str]:
return labels
# ---------------------------------------------------------------------------
# Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models
# ---------------------------------------------------------------------------
# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint
_pricing_cache: dict[str, dict[str, dict[str, str]]] = {}
def _format_price_per_mtok(per_token_str: str) -> str:
"""Convert a per-token price string to a human-friendly $/Mtok string.
Always uses 2 decimal places so that prices align vertically when
right-justified in a column (the decimal point stays in the same position).
Examples:
"0.000003" "$3.00" (per million tokens)
"0.00003" "$30.00"
"0.00000015" "$0.15"
"0.0000001" "$0.10"
"0.00018" "$180.00"
"0" "free"
"""
try:
val = float(per_token_str)
except (TypeError, ValueError):
return "?"
if val == 0:
return "free"
per_m = val * 1_000_000
return f"${per_m:.2f}"
def format_pricing_label(pricing: dict[str, str] | None) -> str:
"""Build a compact pricing label like 'in $3 · out $15 · cache $0.30/Mtok'.
Returns empty string when pricing is unavailable.
"""
if not pricing:
return ""
prompt_price = pricing.get("prompt", "")
completion_price = pricing.get("completion", "")
if not prompt_price and not completion_price:
return ""
inp = _format_price_per_mtok(prompt_price)
out = _format_price_per_mtok(completion_price)
if inp == "free" and out == "free":
return "free"
cache_read = pricing.get("input_cache_read", "")
cache_str = _format_price_per_mtok(cache_read) if cache_read else ""
if inp == out and not cache_str:
return f"{inp}/Mtok"
parts = [f"in {inp}", f"out {out}"]
if cache_str and cache_str != "?" and cache_str != inp:
parts.append(f"cache {cache_str}")
return " · ".join(parts) + "/Mtok"
def format_model_pricing_table(
models: list[tuple[str, str]],
pricing_map: dict[str, dict[str, str]],
current_model: str = "",
indent: str = " ",
) -> list[str]:
"""Build a column-aligned model+pricing table for terminal display.
Returns a list of pre-formatted lines ready to print.
*models* is ``[(model_id, description), ...]``.
"""
if not models:
return []
# Build rows: (model_id, input_price, output_price, cache_price, is_current)
rows: list[tuple[str, str, str, str, bool]] = []
has_cache = False
for mid, _desc in models:
is_cur = mid == current_model
p = pricing_map.get(mid)
if p:
inp = _format_price_per_mtok(p.get("prompt", ""))
out = _format_price_per_mtok(p.get("completion", ""))
cache_read = p.get("input_cache_read", "")
cache = _format_price_per_mtok(cache_read) if cache_read else ""
if cache:
has_cache = True
else:
inp, out, cache = "", "", ""
rows.append((mid, inp, out, cache, is_cur))
name_col = max(len(r[0]) for r in rows) + 2
# Compute price column widths from the actual data so decimals align
price_col = max(
max((len(r[1]) for r in rows if r[1]), default=4),
max((len(r[2]) for r in rows if r[2]), default=4),
3, # minimum: "In" / "Out" header
)
cache_col = max(
max((len(r[3]) for r in rows if r[3]), default=4),
5, # minimum: "Cache" header
) if has_cache else 0
lines: list[str] = []
# Header
if has_cache:
lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} {'Cache':>{cache_col}} /Mtok")
lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col} {'-' * cache_col}")
else:
lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} /Mtok")
lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col}")
for mid, inp, out, cache, is_cur in rows:
marker = " ← current" if is_cur else ""
if has_cache:
lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}} {cache:>{cache_col}}{marker}")
else:
lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}}{marker}")
return lines
def fetch_models_with_pricing(
api_key: str | None = None,
base_url: str = "https://openrouter.ai/api",
timeout: float = 8.0,
*,
force_refresh: bool = False,
) -> dict[str, dict[str, str]]:
"""Fetch ``/v1/models`` and return ``{model_id: {prompt, completion}}`` pricing.
Results are cached per *base_url* so repeated calls are free.
Works with any OpenRouter-compatible endpoint (OpenRouter, Nous Portal).
"""
cache_key = (base_url or "").rstrip("/")
if not force_refresh and cache_key in _pricing_cache:
return _pricing_cache[cache_key]
url = cache_key.rstrip("/") + "/v1/models"
headers: dict[str, str] = {"Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except Exception:
_pricing_cache[cache_key] = {}
return {}
result: dict[str, dict[str, str]] = {}
for item in payload.get("data", []):
mid = item.get("id")
pricing = item.get("pricing")
if mid and isinstance(pricing, dict):
entry: dict[str, str] = {
"prompt": str(pricing.get("prompt", "")),
"completion": str(pricing.get("completion", "")),
}
if pricing.get("input_cache_read"):
entry["input_cache_read"] = str(pricing["input_cache_read"])
if pricing.get("input_cache_write"):
entry["input_cache_write"] = str(pricing["input_cache_write"])
result[mid] = entry
_pricing_cache[cache_key] = result
return result
def _resolve_openrouter_api_key() -> str:
"""Best-effort OpenRouter API key for pricing fetch."""
return os.getenv("OPENROUTER_API_KEY", "").strip()
def _resolve_nous_pricing_credentials() -> tuple[str, str]:
"""Return ``(api_key, base_url)`` for Nous Portal pricing, or empty strings."""
try:
from hermes_cli.auth import resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials()
if creds:
return (creds.get("api_key", ""), creds.get("base_url", ""))
except Exception:
pass
return ("", "")
def get_pricing_for_provider(provider: str) -> dict[str, dict[str, str]]:
"""Return live pricing for providers that support it (openrouter, nous)."""
normalized = normalize_provider(provider)
if normalized == "openrouter":
return fetch_models_with_pricing(
api_key=_resolve_openrouter_api_key(),
base_url="https://openrouter.ai/api",
)
if normalized == "nous":
api_key, base_url = _resolve_nous_pricing_credentials()
if base_url:
# Nous base_url typically looks like https://inference-api.nousresearch.com/v1
# We need the part before /v1 for our fetch function
stripped = base_url.rstrip("/")
if stripped.endswith("/v1"):
stripped = stripped[:-3]
return fetch_models_with_pricing(
api_key=api_key,
base_url=stripped,
)
return {}
# All provider IDs and aliases that are valid for the provider:model syntax.
_KNOWN_PROVIDER_NAMES: set[str] = (
set(_PROVIDER_LABELS.keys())
@@ -333,7 +565,8 @@ def list_available_providers() -> list[dict[str, str]]:
# Canonical providers in display order
_PROVIDER_ORDER = [
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
"gemini", "huggingface",
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
"opencode-zen", "opencode-go",
"ai-gateway", "deepseek", "custom",
]
@@ -948,6 +1181,53 @@ def copilot_model_api_mode(
return "chat_completions"
def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str:
"""Normalize OpenCode config IDs to the bare model slug used in API requests."""
provider = normalize_provider(provider_id)
current = str(model_id or "").strip()
if not current or provider not in {"opencode-zen", "opencode-go"}:
return current
prefix = f"{provider}/"
if current.lower().startswith(prefix):
return current[len(prefix):]
return current
def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str:
"""Determine the API mode for an OpenCode Zen / Go model.
OpenCode routes different models behind different API surfaces:
- GPT-5 / Codex models on Zen use ``/v1/responses``
- Claude models on Zen use ``/v1/messages``
- MiniMax models on Go use ``/v1/messages``
- GLM / Kimi on Go use ``/v1/chat/completions``
- Other Zen models (Gemini, GLM, Kimi, MiniMax, Qwen, etc.) use
``/v1/chat/completions``
This follows the published OpenCode docs for Zen and Go endpoints.
"""
provider = normalize_provider(provider_id)
normalized = normalize_opencode_model_id(provider_id, model_id).lower()
if not normalized:
return "chat_completions"
if provider == "opencode-go":
if normalized.startswith("minimax-"):
return "anthropic_messages"
return "chat_completions"
if provider == "opencode-zen":
if normalized.startswith("claude-"):
return "anthropic_messages"
if normalized.startswith("gpt-"):
return "codex_responses"
return "chat_completions"
return "chat_completions"
def github_model_reasoning_efforts(
model_id: Optional[str],
*,
+524
View File
@@ -0,0 +1,524 @@
"""Helpers for Nous subscription managed-tool capabilities."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional, Set
from hermes_cli.auth import get_nous_auth_status
from hermes_cli.config import get_env_value, load_config
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from tools.tool_backend_helpers import (
has_direct_modal_credentials,
managed_nous_tools_enabled,
normalize_browser_cloud_provider,
normalize_modal_mode,
resolve_modal_backend_state,
resolve_openai_audio_api_key,
)
_DEFAULT_PLATFORM_TOOLSETS = {
"cli": "hermes-cli",
}
@dataclass(frozen=True)
class NousFeatureState:
key: str
label: str
included_by_default: bool
available: bool
active: bool
managed_by_nous: bool
direct_override: bool
toolset_enabled: bool
current_provider: str = ""
explicit_configured: bool = False
@dataclass(frozen=True)
class NousSubscriptionFeatures:
subscribed: bool
nous_auth_present: bool
provider_is_nous: bool
features: Dict[str, NousFeatureState]
@property
def web(self) -> NousFeatureState:
return self.features["web"]
@property
def image_gen(self) -> NousFeatureState:
return self.features["image_gen"]
@property
def tts(self) -> NousFeatureState:
return self.features["tts"]
@property
def browser(self) -> NousFeatureState:
return self.features["browser"]
@property
def modal(self) -> NousFeatureState:
return self.features["modal"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "tts", "browser", "modal")
for key in ordered:
yield self.features[key]
def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]:
model_cfg = config.get("model")
if isinstance(model_cfg, dict):
return dict(model_cfg)
if isinstance(model_cfg, str) and model_cfg.strip():
return {"default": model_cfg.strip()}
return {}
def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
from toolsets import resolve_toolset
platform_toolsets = config.get("platform_toolsets")
if not isinstance(platform_toolsets, dict) or not platform_toolsets:
platform_toolsets = {"cli": [_DEFAULT_PLATFORM_TOOLSETS["cli"]]}
target_tools = set(resolve_toolset(toolset_key))
if not target_tools:
return False
for platform, raw_toolsets in platform_toolsets.items():
if isinstance(raw_toolsets, list):
toolset_names = list(raw_toolsets)
else:
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
toolset_names = [default_toolset] if default_toolset else []
if not toolset_names:
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
if default_toolset:
toolset_names = [default_toolset]
available_tools: Set[str] = set()
for toolset_name in toolset_names:
if not isinstance(toolset_name, str) or not toolset_name:
continue
try:
available_tools.update(resolve_toolset(toolset_name))
except Exception:
continue
if target_tools and target_tools.issubset(available_tools):
return True
return False
def _has_agent_browser() -> bool:
import shutil
agent_browser_bin = shutil.which("agent-browser")
local_bin = (
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
)
return bool(agent_browser_bin or local_bin.exists())
def _browser_label(current_provider: str) -> str:
mapping = {
"browserbase": "Browserbase",
"browser-use": "Browser Use",
"firecrawl": "Firecrawl",
"camofox": "Camofox",
"local": "Local browser",
}
return mapping.get(current_provider or "local", current_provider or "Local browser")
def _tts_label(current_provider: str) -> str:
mapping = {
"openai": "OpenAI TTS",
"elevenlabs": "ElevenLabs",
"edge": "Edge TTS",
"neutts": "NeuTTS",
}
return mapping.get(current_provider or "edge", current_provider or "Edge TTS")
def _resolve_browser_feature_state(
*,
browser_tool_enabled: bool,
browser_provider: str,
browser_provider_explicit: bool,
browser_local_available: bool,
direct_camofox: bool,
direct_browserbase: bool,
direct_browser_use: bool,
direct_firecrawl: bool,
managed_browser_available: bool,
) -> tuple[str, bool, bool, bool]:
"""Resolve browser availability using the same precedence as runtime."""
if direct_camofox:
return "camofox", True, bool(browser_tool_enabled), False
if browser_provider_explicit:
current_provider = browser_provider or "local"
if current_provider == "browserbase":
provider_available = managed_browser_available or direct_browserbase
available = bool(browser_local_available and provider_available)
managed = bool(
browser_tool_enabled
and browser_local_available
and managed_browser_available
and not direct_browserbase
)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, managed
if current_provider == "browser-use":
available = bool(browser_local_available and direct_browser_use)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
if current_provider == "firecrawl":
available = bool(browser_local_available and direct_firecrawl)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
if current_provider == "camofox":
return current_provider, False, False, False
current_provider = "local"
available = bool(browser_local_available)
active = bool(browser_tool_enabled and available)
return current_provider, available, active, False
if managed_browser_available or direct_browserbase:
available = bool(browser_local_available)
managed = bool(
browser_tool_enabled
and browser_local_available
and managed_browser_available
and not direct_browserbase
)
active = bool(browser_tool_enabled and available)
return "browserbase", available, active, managed
available = bool(browser_local_available)
active = bool(browser_tool_enabled and available)
return "local", available, active, False
def get_nous_subscription_features(
config: Optional[Dict[str, object]] = None,
) -> NousSubscriptionFeatures:
if config is None:
config = load_config() or {}
config = dict(config)
model_cfg = _model_config_dict(config)
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
try:
nous_status = get_nous_auth_status()
except Exception:
nous_status = {}
managed_tools_flag = managed_nous_tools_enabled()
nous_auth_present = bool(nous_status.get("logged_in"))
subscribed = provider_is_nous or nous_auth_present
web_tool_enabled = _toolset_enabled(config, "web")
image_tool_enabled = _toolset_enabled(config, "image_gen")
tts_tool_enabled = _toolset_enabled(config, "tts")
browser_tool_enabled = _toolset_enabled(config, "browser")
modal_tool_enabled = _toolset_enabled(config, "terminal")
web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {}
tts_cfg = config.get("tts") if isinstance(config.get("tts"), dict) else {}
browser_cfg = config.get("browser") if isinstance(config.get("browser"), dict) else {}
terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {}
web_backend = str(web_cfg.get("backend") or "").strip().lower()
tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower()
browser_provider_explicit = "cloud_provider" in browser_cfg
browser_provider = normalize_browser_cloud_provider(
browser_cfg.get("cloud_provider") if browser_provider_explicit else None
)
terminal_backend = (
str(terminal_cfg.get("backend") or "local").strip().lower()
)
modal_mode = normalize_modal_mode(
terminal_cfg.get("modal_mode")
)
direct_exa = bool(get_env_value("EXA_API_KEY"))
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
direct_parallel = bool(get_env_value("PARALLEL_API_KEY"))
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
direct_fal = bool(get_env_value("FAL_KEY"))
direct_openai_tts = bool(resolve_openai_audio_api_key())
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
direct_modal = has_direct_modal_credentials()
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browserbase")
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
modal_state = resolve_modal_backend_state(
modal_mode,
has_direct=direct_modal,
managed_ready=managed_modal_available,
)
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
web_active = bool(
web_tool_enabled
and (
web_managed
or (web_backend == "exa" and direct_exa)
or (web_backend == "firecrawl" and direct_firecrawl)
or (web_backend == "parallel" and direct_parallel)
or (web_backend == "tavily" and direct_tavily)
)
)
web_available = bool(
managed_web_available or direct_exa or direct_firecrawl or direct_parallel or direct_tavily
)
image_managed = image_tool_enabled and managed_image_available and not direct_fal
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
image_available = bool(managed_image_available or direct_fal)
tts_current_provider = tts_provider or "edge"
tts_managed = (
tts_tool_enabled
and tts_current_provider == "openai"
and managed_tts_available
and not direct_openai_tts
)
tts_available = bool(
tts_current_provider in {"edge", "neutts"}
or (tts_current_provider == "openai" and (managed_tts_available or direct_openai_tts))
or (tts_current_provider == "elevenlabs" and direct_elevenlabs)
)
tts_active = bool(tts_tool_enabled and tts_available)
browser_local_available = _has_agent_browser()
(
browser_current_provider,
browser_available,
browser_active,
browser_managed,
) = _resolve_browser_feature_state(
browser_tool_enabled=browser_tool_enabled,
browser_provider=browser_provider,
browser_provider_explicit=browser_provider_explicit,
browser_local_available=browser_local_available,
direct_camofox=direct_camofox,
direct_browserbase=direct_browserbase,
direct_browser_use=direct_browser_use,
direct_firecrawl=direct_firecrawl,
managed_browser_available=managed_browser_available,
)
if terminal_backend != "modal":
modal_managed = False
modal_available = True
modal_active = bool(modal_tool_enabled)
modal_direct_override = False
elif modal_state["selected_backend"] == "managed":
modal_managed = bool(modal_tool_enabled)
modal_available = True
modal_active = bool(modal_tool_enabled)
modal_direct_override = False
elif modal_state["selected_backend"] == "direct":
modal_managed = False
modal_available = True
modal_active = bool(modal_tool_enabled)
modal_direct_override = bool(modal_tool_enabled)
elif modal_mode == "managed":
modal_managed = False
modal_available = bool(managed_modal_available)
modal_active = False
modal_direct_override = False
elif modal_mode == "direct":
modal_managed = False
modal_available = bool(direct_modal)
modal_active = False
modal_direct_override = False
else:
modal_managed = False
modal_available = bool(managed_modal_available or direct_modal)
modal_active = False
modal_direct_override = False
tts_explicit_configured = False
raw_tts_cfg = config.get("tts")
if isinstance(raw_tts_cfg, dict) and "provider" in raw_tts_cfg:
tts_explicit_configured = tts_provider not in {"", "edge"}
features = {
"web": NousFeatureState(
key="web",
label="Web tools",
included_by_default=True,
available=web_available,
active=web_active,
managed_by_nous=web_managed,
direct_override=web_active and not web_managed,
toolset_enabled=web_tool_enabled,
current_provider=web_backend or "",
explicit_configured=bool(web_backend),
),
"image_gen": NousFeatureState(
key="image_gen",
label="Image generation",
included_by_default=True,
available=image_available,
active=image_active,
managed_by_nous=image_managed,
direct_override=image_active and not image_managed,
toolset_enabled=image_tool_enabled,
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
explicit_configured=direct_fal,
),
"tts": NousFeatureState(
key="tts",
label="OpenAI TTS",
included_by_default=True,
available=tts_available,
active=tts_active,
managed_by_nous=tts_managed,
direct_override=tts_active and not tts_managed,
toolset_enabled=tts_tool_enabled,
current_provider=_tts_label(tts_current_provider),
explicit_configured=tts_explicit_configured,
),
"browser": NousFeatureState(
key="browser",
label="Browser automation",
included_by_default=True,
available=browser_available,
active=browser_active,
managed_by_nous=browser_managed,
direct_override=browser_active and not browser_managed,
toolset_enabled=browser_tool_enabled,
current_provider=_browser_label(browser_current_provider),
explicit_configured=browser_provider_explicit,
),
"modal": NousFeatureState(
key="modal",
label="Modal execution",
included_by_default=False,
available=modal_available,
active=modal_active,
managed_by_nous=modal_managed,
direct_override=terminal_backend == "modal" and modal_direct_override,
toolset_enabled=modal_tool_enabled,
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
explicit_configured=terminal_backend == "modal",
),
}
return NousSubscriptionFeatures(
subscribed=subscribed,
nous_auth_present=nous_auth_present,
provider_is_nous=provider_is_nous,
features=features,
)
def get_nous_subscription_explainer_lines() -> list[str]:
if not managed_nous_tools_enabled():
return []
return [
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
"Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.",
"Change these later with: hermes setup tools, hermes setup terminal, or hermes status.",
]
def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]:
"""Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`."""
if not managed_nous_tools_enabled():
return set()
features = get_nous_subscription_features(config)
if not features.provider_is_nous:
return set()
tts_cfg = config.get("tts")
if not isinstance(tts_cfg, dict):
tts_cfg = {}
config["tts"] = tts_cfg
current_tts = str(tts_cfg.get("provider") or "edge").strip().lower()
if current_tts not in {"", "edge"}:
return set()
tts_cfg["provider"] = "openai"
return {"tts"}
def apply_nous_managed_defaults(
config: Dict[str, object],
*,
enabled_toolsets: Optional[Iterable[str]] = None,
) -> set[str]:
if not managed_nous_tools_enabled():
return set()
features = get_nous_subscription_features(config)
if not features.provider_is_nous:
return set()
selected_toolsets = set(enabled_toolsets or ())
changed: set[str] = set()
web_cfg = config.get("web")
if not isinstance(web_cfg, dict):
web_cfg = {}
config["web"] = web_cfg
tts_cfg = config.get("tts")
if not isinstance(tts_cfg, dict):
tts_cfg = {}
config["tts"] = tts_cfg
browser_cfg = config.get("browser")
if not isinstance(browser_cfg, dict):
browser_cfg = {}
config["browser"] = browser_cfg
if "web" in selected_toolsets and not features.web.explicit_configured and not (
get_env_value("PARALLEL_API_KEY")
or get_env_value("TAVILY_API_KEY")
or get_env_value("FIRECRAWL_API_KEY")
or get_env_value("FIRECRAWL_API_URL")
):
web_cfg["backend"] = "firecrawl"
changed.add("web")
if "tts" in selected_toolsets and not features.tts.explicit_configured and not (
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
):
tts_cfg["provider"] = "openai"
changed.add("tts")
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
get_env_value("BROWSERBASE_API_KEY")
or get_env_value("BROWSER_USE_API_KEY")
):
browser_cfg["cloud_provider"] = "browserbase"
changed.add("browser")
if "image_gen" in selected_toolsets and not get_env_value("FAL_KEY"):
changed.add("image_gen")
return changed
+55 -5
View File
@@ -36,7 +36,9 @@ import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set
from typing import Any, Callable, Dict, List, Optional, Set, Union
from utils import env_var_enabled
try:
import yaml
@@ -54,6 +56,8 @@ VALID_HOOKS: Set[str] = {
"post_tool_call",
"pre_llm_call",
"post_llm_call",
"pre_api_request",
"post_api_request",
"on_session_start",
"on_session_end",
}
@@ -65,7 +69,7 @@ _NS_PARENT = "hermes_plugins"
def _env_enabled(name: str) -> bool:
"""Return True when an env var is set to a truthy opt-in value."""
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
return env_var_enabled(name)
def _get_disabled_plugins() -> set:
@@ -91,7 +95,7 @@ class PluginManifest:
version: str = ""
description: str = ""
author: str = ""
requires_env: List[str] = field(default_factory=list)
requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list)
provides_tools: List[str] = field(default_factory=list)
provides_hooks: List[str] = field(default_factory=list)
source: str = "" # "user", "project", or "entrypoint"
@@ -180,6 +184,32 @@ class PluginContext:
cli._pending_input.put(msg)
return True
# -- CLI command registration --------------------------------------------
def register_cli_command(
self,
name: str,
help: str,
setup_fn: Callable,
handler_fn: Callable | None = None,
description: str = "",
) -> None:
"""Register a CLI subcommand (e.g. ``hermes honcho ...``).
The *setup_fn* receives an argparse subparser and should add any
arguments/sub-subparsers. If *handler_fn* is provided it is set
as the default dispatch function via ``set_defaults(func=...)``.
"""
self._manager._cli_commands[name] = {
"name": name,
"help": help,
"description": description,
"setup_fn": setup_fn,
"handler_fn": handler_fn,
"plugin": self.manifest.name,
}
logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name)
# -- hook registration --------------------------------------------------
def register_hook(self, hook_name: str, callback: Callable) -> None:
@@ -211,6 +241,7 @@ class PluginManager:
self._plugins: Dict[str, LoadedPlugin] = {}
self._hooks: Dict[str, List[Callable]] = {}
self._plugin_tool_names: Set[str] = set()
self._cli_commands: Dict[str, dict] = {}
self._discovered: bool = False
self._cli_ref = None # Set by CLI after plugin discovery
@@ -439,8 +470,18 @@ class PluginManager:
plugin cannot break the core agent loop.
Returns a list of non-``None`` return values from callbacks.
This allows hooks like ``pre_llm_call`` to contribute context
that the agent core can collect and inject.
For ``pre_llm_call``, callbacks may return a dict describing
context to inject into the current turn's user message::
{"context": "recalled text..."}
"recalled text..." # plain string, equivalent
Context is ALWAYS injected into the user message, never the
system prompt. This preserves the prompt cache prefix the
system prompt stays identical across turns so cached tokens
are reused. All injected context is ephemeral never
persisted to session DB.
"""
callbacks = self._hooks.get(hook_name, [])
results: List[Any] = []
@@ -514,6 +555,15 @@ def get_plugin_tool_names() -> Set[str]:
return get_plugin_manager()._plugin_tool_names
def get_plugin_cli_commands() -> Dict[str, dict]:
"""Return CLI commands registered by general plugins.
Returns a dict of ``{name: {help, setup_fn, handler_fn, ...}}``
suitable for wiring into argparse subparsers.
"""
return dict(get_plugin_manager()._cli_commands)
def get_plugin_toolsets() -> List[tuple]:
"""Return plugin toolsets as ``(key, label, description)`` tuples.
+95 -4
View File
@@ -41,6 +41,11 @@ def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
if not name:
raise ValueError("Plugin name must not be empty.")
if name in (".", ".."):
raise ValueError(
f"Invalid plugin name '{name}': must not reference the plugins directory itself."
)
# Reject obvious traversal characters
for bad in ("/", "\\", ".."):
if bad in name:
@@ -49,10 +54,14 @@ def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
target = (plugins_dir / name).resolve()
plugins_resolved = plugins_dir.resolve()
if (
not str(target).startswith(str(plugins_resolved) + os.sep)
and target != plugins_resolved
):
if target == plugins_resolved:
raise ValueError(
f"Invalid plugin name '{name}': resolves to the plugins directory itself."
)
try:
target.relative_to(plugins_resolved)
except ValueError:
raise ValueError(
f"Invalid plugin name '{name}': resolves outside the plugins directory."
)
@@ -138,6 +147,82 @@ def _copy_example_files(plugin_dir: Path, console) -> None:
)
def _prompt_plugin_env_vars(manifest: dict, console) -> None:
"""Prompt for required environment variables declared in plugin.yaml.
``requires_env`` accepts two formats:
Simple list (backwards-compatible)::
requires_env:
- MY_API_KEY
Rich list with metadata::
requires_env:
- name: MY_API_KEY
description: "API key for Acme service"
url: "https://acme.com/keys"
secret: true
Already-set variables are skipped. Values are saved to the user's ``.env``.
"""
requires_env = manifest.get("requires_env") or []
if not requires_env:
return
from hermes_cli.config import get_env_value, save_env_value # noqa: F811
from hermes_constants import display_hermes_home
# Normalise to list-of-dicts
env_specs: list[dict] = []
for entry in requires_env:
if isinstance(entry, str):
env_specs.append({"name": entry})
elif isinstance(entry, dict) and entry.get("name"):
env_specs.append(entry)
# Filter to only vars that aren't already set
missing = [s for s in env_specs if not get_env_value(s["name"])]
if not missing:
return
plugin_name = manifest.get("name", "this plugin")
console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n")
for spec in missing:
name = spec["name"]
desc = spec.get("description", "")
url = spec.get("url", "")
secret = spec.get("secret", False)
label = f" {name}"
if desc:
label += f"{desc}"
console.print(label)
if url:
console.print(f" [dim]Get yours at: {url}[/dim]")
try:
if secret:
import getpass
value = getpass.getpass(f" {name}: ").strip()
else:
value = input(f" {name}: ").strip()
except (EOFError, KeyboardInterrupt):
console.print(f"\n[dim] Skipped (you can set these later in {display_hermes_home()}/.env)[/dim]")
return
if value:
save_env_value(name, value)
os.environ[name] = value
console.print(f" [green]✓[/green] Saved to {display_hermes_home()}/.env")
else:
console.print(f" [dim] Skipped (set {name} in {display_hermes_home()}/.env later)[/dim]")
console.print()
def _display_after_install(plugin_dir: Path, identifier: str) -> None:
"""Show after-install.md if it exists, otherwise a default message."""
from rich.console import Console
@@ -297,6 +382,12 @@ def cmd_install(identifier: str, force: bool = False) -> None:
# Copy .example files to their real names (e.g. config.yaml.example → config.yaml)
_copy_example_files(target, console)
# Re-read manifest from installed location (for env var prompting)
installed_manifest = _read_manifest(target)
# Prompt for required environment variables before showing after-install docs
_prompt_plugin_env_vars(installed_manifest, console)
_display_after_install(target, identifier)
console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]")
+16
View File
@@ -51,6 +51,14 @@ _CLONE_CONFIG_FILES = [
"SOUL.md",
]
# Subdirectory files copied during --clone (path relative to profile root).
# Memory files are part of the agent's curated identity — just as important
# as SOUL.md for continuity when cloning a profile.
_CLONE_SUBDIR_FILES = [
"memories/MEMORY.md",
"memories/USER.md",
]
# Runtime files stripped after --clone-all (shouldn't carry over)
_CLONE_ALL_STRIP = [
"gateway.pid",
@@ -428,6 +436,14 @@ def create_profile(
if src.exists():
shutil.copy2(src, profile_dir / filename)
# Clone memory and other subdirectory files
for relpath in _CLONE_SUBDIR_FILES:
src = source_dir / relpath
if src.exists():
dst = profile_dir / relpath
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
return profile_dir
+519
View File
@@ -0,0 +1,519 @@
"""
Single source of truth for provider identity in Hermes Agent.
Two data sources, merged at runtime:
1. **models.dev catalog** 109+ providers with base URLs, env vars, display
names, and full model metadata (context, cost, capabilities). This is
the primary database.
2. **Hermes overlays** transport type, auth patterns, aggregator flags,
and additional env vars that models.dev doesn't track. Small dict,
maintained here.
3. **User config** (``providers:`` section in config.yaml) user-defined
endpoints and overrides. Merged on top of everything else.
Other modules import from this file. No parallel registries.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# -- Hermes overlay ----------------------------------------------------------
# Hermes-specific metadata that models.dev doesn't provide.
@dataclass(frozen=True)
class HermesOverlay:
"""Hermes-specific provider metadata layered on top of models.dev."""
transport: str = "openai_chat" # openai_chat | anthropic_messages | codex_responses
is_aggregator: bool = False
auth_type: str = "api_key" # api_key | oauth_device_code | oauth_external | external_process
extra_env_vars: Tuple[str, ...] = () # env vars models.dev doesn't list
base_url_override: str = "" # override if models.dev URL is wrong/missing
base_url_env_var: str = "" # env var for user-custom base URL
HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
"openrouter": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
extra_env_vars=("OPENAI_API_KEY",),
base_url_env_var="OPENROUTER_BASE_URL",
),
"nous": HermesOverlay(
transport="openai_chat",
auth_type="oauth_device_code",
base_url_override="https://inference-api.nousresearch.com/v1",
),
"openai-codex": HermesOverlay(
transport="codex_responses",
auth_type="oauth_external",
base_url_override="https://chatgpt.com/backend-api/codex",
),
"copilot-acp": HermesOverlay(
transport="codex_responses",
auth_type="external_process",
base_url_override="acp://copilot",
base_url_env_var="COPILOT_ACP_BASE_URL",
),
"github-copilot": HermesOverlay(
transport="openai_chat",
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
),
"anthropic": HermesOverlay(
transport="anthropic_messages",
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
),
"zai": HermesOverlay(
transport="openai_chat",
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
base_url_env_var="GLM_BASE_URL",
),
"kimi-for-coding": HermesOverlay(
transport="openai_chat",
base_url_env_var="KIMI_BASE_URL",
),
"minimax": HermesOverlay(
transport="openai_chat",
base_url_env_var="MINIMAX_BASE_URL",
),
"minimax-cn": HermesOverlay(
transport="openai_chat",
base_url_env_var="MINIMAX_CN_BASE_URL",
),
"deepseek": HermesOverlay(
transport="openai_chat",
base_url_env_var="DEEPSEEK_BASE_URL",
),
"alibaba": HermesOverlay(
transport="openai_chat",
base_url_env_var="DASHSCOPE_BASE_URL",
),
"vercel": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
),
"opencode": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
base_url_env_var="OPENCODE_ZEN_BASE_URL",
),
"opencode-go": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
base_url_env_var="OPENCODE_GO_BASE_URL",
),
"kilo": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
base_url_env_var="KILOCODE_BASE_URL",
),
"huggingface": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
base_url_env_var="HF_BASE_URL",
),
}
# -- Resolved provider -------------------------------------------------------
# The merged result of models.dev + overlay + user config.
@dataclass
class ProviderDef:
"""Complete provider definition — merged from all sources."""
id: str
name: str
transport: str # openai_chat | anthropic_messages | codex_responses
api_key_env_vars: Tuple[str, ...] # all env vars to check for API key
base_url: str = ""
base_url_env_var: str = ""
is_aggregator: bool = False
auth_type: str = "api_key"
doc: str = ""
source: str = "" # "models.dev", "hermes", "user-config"
@property
def is_user_defined(self) -> bool:
return self.source == "user-config"
# -- Aliases ------------------------------------------------------------------
# Maps human-friendly / legacy names to canonical provider IDs.
# Uses models.dev IDs where possible.
ALIASES: Dict[str, str] = {
# openrouter
"openai": "openrouter", # bare "openai" → route through aggregator
# zai
"glm": "zai",
"z-ai": "zai",
"z.ai": "zai",
"zhipu": "zai",
# kimi-for-coding (models.dev ID)
"kimi": "kimi-for-coding",
"kimi-coding": "kimi-for-coding",
"moonshot": "kimi-for-coding",
# minimax-cn
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
# anthropic
"claude": "anthropic",
"claude-code": "anthropic",
# github-copilot (models.dev ID)
"copilot": "github-copilot",
"github": "github-copilot",
"github-copilot-acp": "copilot-acp",
# vercel (models.dev ID for AI Gateway)
"ai-gateway": "vercel",
"aigateway": "vercel",
"vercel-ai-gateway": "vercel",
# opencode (models.dev ID for OpenCode Zen)
"opencode-zen": "opencode",
"zen": "opencode",
# opencode-go
"go": "opencode-go",
"opencode-go-sub": "opencode-go",
# kilo (models.dev ID for KiloCode)
"kilocode": "kilo",
"kilo-code": "kilo",
"kilo-gateway": "kilo",
# deepseek
"deep-seek": "deepseek",
# alibaba
"dashscope": "alibaba",
"aliyun": "alibaba",
"qwen": "alibaba",
"alibaba-cloud": "alibaba",
# huggingface
"hf": "huggingface",
"hugging-face": "huggingface",
"huggingface-hub": "huggingface",
# Local server aliases → virtual "local" concept (resolved via user config)
"lmstudio": "lmstudio",
"lm-studio": "lmstudio",
"lm_studio": "lmstudio",
"ollama": "ollama-cloud",
"vllm": "local",
"llamacpp": "local",
"llama.cpp": "local",
"llama-cpp": "local",
}
# -- Display labels -----------------------------------------------------------
# Built dynamically from models.dev + overlays. Fallback for providers
# not in the catalog.
_LABEL_OVERRIDES: Dict[str, str] = {
"nous": "Nous Portal",
"openai-codex": "OpenAI Codex",
"copilot-acp": "GitHub Copilot ACP",
"local": "Local endpoint",
}
# -- Transport → API mode mapping ---------------------------------------------
TRANSPORT_TO_API_MODE: Dict[str, str] = {
"openai_chat": "chat_completions",
"anthropic_messages": "anthropic_messages",
"codex_responses": "codex_responses",
}
# -- Helper functions ---------------------------------------------------------
def normalize_provider(name: str) -> str:
"""Resolve aliases and normalise casing to a canonical provider id.
Returns the canonical id string. Does *not* validate that the id
corresponds to a known provider.
"""
key = name.strip().lower()
return ALIASES.get(key, key)
def get_overlay(provider_id: str) -> Optional[HermesOverlay]:
"""Get Hermes overlay for a provider, if one exists."""
canonical = normalize_provider(provider_id)
return HERMES_OVERLAYS.get(canonical)
def get_provider(name: str) -> Optional[ProviderDef]:
"""Look up a provider by id or alias, merging all data sources.
Resolution order:
1. Hermes overlays (for providers not in models.dev: nous, openai-codex, etc.)
2. models.dev catalog + Hermes overlay
3. User-defined providers from config (TODO: Phase 4)
Returns a fully-resolved ProviderDef or None.
"""
canonical = normalize_provider(name)
# Try to get models.dev data
try:
from agent.models_dev import get_provider_info as _mdev_provider
mdev_info = _mdev_provider(canonical)
except Exception:
mdev_info = None
overlay = HERMES_OVERLAYS.get(canonical)
if mdev_info is not None:
# Merge models.dev + overlay
transport = overlay.transport if overlay else "openai_chat"
is_agg = overlay.is_aggregator if overlay else False
auth = overlay.auth_type if overlay else "api_key"
base_url_env = overlay.base_url_env_var if overlay else ""
base_url_override = overlay.base_url_override if overlay else ""
# Combine env vars: models.dev env + hermes extra
env_vars = list(mdev_info.env)
if overlay and overlay.extra_env_vars:
for ev in overlay.extra_env_vars:
if ev not in env_vars:
env_vars.append(ev)
return ProviderDef(
id=canonical,
name=mdev_info.name,
transport=transport,
api_key_env_vars=tuple(env_vars),
base_url=base_url_override or mdev_info.api,
base_url_env_var=base_url_env,
is_aggregator=is_agg,
auth_type=auth,
doc=mdev_info.doc,
source="models.dev",
)
if overlay is not None:
# Hermes-only provider (not in models.dev)
return ProviderDef(
id=canonical,
name=_LABEL_OVERRIDES.get(canonical, canonical),
transport=overlay.transport,
api_key_env_vars=overlay.extra_env_vars,
base_url=overlay.base_url_override,
base_url_env_var=overlay.base_url_env_var,
is_aggregator=overlay.is_aggregator,
auth_type=overlay.auth_type,
source="hermes",
)
return None
def get_label(provider_id: str) -> str:
"""Get a human-readable display name for a provider."""
canonical = normalize_provider(provider_id)
# Check label overrides first
if canonical in _LABEL_OVERRIDES:
return _LABEL_OVERRIDES[canonical]
# Try models.dev
pdef = get_provider(canonical)
if pdef:
return pdef.name
return canonical
# Build LABELS dict for backward compat
def _build_labels() -> Dict[str, str]:
"""Build labels dict from overlays + overrides. Lazy, cached."""
labels: Dict[str, str] = {}
for pid in HERMES_OVERLAYS:
labels[pid] = get_label(pid)
labels.update(_LABEL_OVERRIDES)
return labels
# Lazy-built on first access
_labels_cache: Optional[Dict[str, str]] = None
@property
def LABELS() -> Dict[str, str]:
"""Backward-compatible labels dict."""
global _labels_cache
if _labels_cache is None:
_labels_cache = _build_labels()
return _labels_cache
# For direct import compat, expose as module-level dict
# Built on demand by get_label() calls
LABELS: Dict[str, str] = {
# Static entries for backward compat — get_label() is the proper API
"openrouter": "OpenRouter",
"nous": "Nous Portal",
"openai-codex": "OpenAI Codex",
"copilot-acp": "GitHub Copilot ACP",
"github-copilot": "GitHub Copilot",
"anthropic": "Anthropic",
"zai": "Z.AI / GLM",
"kimi-for-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"deepseek": "DeepSeek",
"alibaba": "Alibaba Cloud (DashScope)",
"vercel": "Vercel AI Gateway",
"opencode": "OpenCode Zen",
"opencode-go": "OpenCode Go",
"kilo": "Kilo Gateway",
"huggingface": "Hugging Face",
"local": "Local endpoint",
"custom": "Custom endpoint",
# Legacy Hermes IDs (point to same providers)
"ai-gateway": "Vercel AI Gateway",
"kilocode": "Kilo Gateway",
"copilot": "GitHub Copilot",
"kimi-coding": "Kimi / Moonshot",
"opencode-zen": "OpenCode Zen",
}
def is_aggregator(provider: str) -> bool:
"""Return True when the provider is a multi-model aggregator."""
pdef = get_provider(provider)
return pdef.is_aggregator if pdef else False
def determine_api_mode(provider: str, base_url: str = "") -> str:
"""Determine the API mode (wire protocol) for a provider/endpoint.
Resolution order:
1. Known provider transport TRANSPORT_TO_API_MODE.
2. URL heuristics for unknown / custom providers.
3. Default: 'chat_completions'.
"""
pdef = get_provider(provider)
if pdef is not None:
return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")
# URL-based heuristics for custom / unknown providers
if base_url:
url_lower = base_url.rstrip("/").lower()
if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
return "anthropic_messages"
if "api.openai.com" in url_lower:
return "codex_responses"
return "chat_completions"
# -- Provider from user config ------------------------------------------------
def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[ProviderDef]:
"""Resolve a provider from the user's config.yaml ``providers:`` section.
Args:
name: Provider name as given by the user.
user_config: The ``providers:`` dict from config.yaml.
Returns:
ProviderDef if found, else None.
"""
if not user_config or not isinstance(user_config, dict):
return None
entry = user_config.get(name)
if not isinstance(entry, dict):
return None
# Extract fields
display_name = entry.get("name", "") or name
api_url = entry.get("api", "") or entry.get("url", "") or entry.get("base_url", "") or ""
key_env = entry.get("key_env", "") or ""
transport = entry.get("transport", "openai_chat") or "openai_chat"
env_vars: List[str] = []
if key_env:
env_vars.append(key_env)
return ProviderDef(
id=name,
name=display_name,
transport=transport,
api_key_env_vars=tuple(env_vars),
base_url=api_url,
is_aggregator=False,
auth_type="api_key",
source="user-config",
)
def resolve_provider_full(
name: str,
user_providers: Optional[Dict[str, Any]] = None,
) -> Optional[ProviderDef]:
"""Full resolution chain: built-in → models.dev → user config.
This is the main entry point for --provider flag resolution.
Args:
name: Provider name or alias.
user_providers: The ``providers:`` dict from config.yaml (optional).
Returns:
ProviderDef if found, else None.
"""
canonical = normalize_provider(name)
# 1. Built-in (models.dev + overlays)
pdef = get_provider(canonical)
if pdef is not None:
return pdef
# 2. User-defined providers from config
if user_providers:
# Try canonical name
user_pdef = resolve_user_provider(canonical, user_providers)
if user_pdef is not None:
return user_pdef
# Try original name (in case alias didn't match)
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
if user_pdef is not None:
return user_pdef
# 3. Try models.dev directly (for providers not in our ALIASES)
try:
from agent.models_dev import get_provider_info as _mdev_provider
mdev_info = _mdev_provider(canonical)
if mdev_info is not None:
return ProviderDef(
id=canonical,
name=mdev_info.name,
transport="openai_chat",
api_key_env_vars=mdev_info.env,
base_url=mdev_info.api,
source="models.dev",
)
except Exception:
pass
return None
+59 -5
View File
@@ -2,9 +2,13 @@
from __future__ import annotations
import logging
import os
import re
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
from hermes_cli import auth as auth_mod
from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool
from hermes_cli.auth import (
@@ -82,9 +86,27 @@ def _get_model_config() -> Dict[str, Any]:
return {}
def _provider_supports_explicit_api_mode(provider: Optional[str], configured_provider: Optional[str] = None) -> bool:
"""Check whether a persisted api_mode should be honored for a given provider.
Prevents stale api_mode from a previous provider leaking into a
different one after a model/provider switch. Only applies the
persisted mode when the config's provider matches the runtime
provider (or when no configured provider is recorded).
"""
normalized_provider = (provider or "").strip().lower()
normalized_configured = (configured_provider or "").strip().lower()
if not normalized_configured:
return True
if normalized_provider == "custom":
return normalized_configured == "custom" or normalized_configured.startswith("custom:")
return normalized_configured == normalized_provider
def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if configured_mode:
if configured_mode and _provider_supports_explicit_api_mode("copilot", configured_provider):
return configured_mode
model_name = str(model_cfg.get("default") or "").strip()
@@ -140,12 +162,23 @@ def _resolve_runtime_from_pool_entry(
elif provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
else:
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if configured_mode:
if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
api_mode = configured_mode
elif provider in ("opencode-zen", "opencode-go"):
from hermes_cli.models import opencode_model_api_mode
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
elif base_url.rstrip("/").endswith("/anthropic"):
api_mode = "anthropic_messages"
# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
# Anthropic SDK prepends its own /v1/messages to the base_url. Strip the
# trailing /v1 so the SDK constructs the correct path (e.g.
# https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages).
if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"):
base_url = re.sub(r"/v1/?$", "", base_url)
return {
"provider": provider,
"api_mode": api_mode,
@@ -228,6 +261,12 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
config = load_config()
custom_providers = config.get("custom_providers")
if not isinstance(custom_providers, list):
if isinstance(custom_providers, dict):
logger.warning(
"custom_providers in config.yaml is a dict, not a list. "
"Each entry must be prefixed with '-' in YAML. "
"Run 'hermes doctor' for details."
)
return None
for entry in custom_providers:
@@ -347,9 +386,13 @@ def _resolve_openrouter_runtime(
]
else:
# Custom endpoint: use api_key from config when using config base_url (#1760).
# When the endpoint is Ollama Cloud, check OLLAMA_API_KEY — it's
# the canonical env var for ollama.com authentication.
_is_ollama_url = "ollama.com" in base_url.lower()
api_key_candidates = [
explicit_api_key,
(cfg_api_key if use_config_base_url else ""),
(os.getenv("OLLAMA_API_KEY") if _is_ollama_url else ""),
os.getenv("OPENAI_API_KEY"),
os.getenv("OPENROUTER_API_KEY"),
]
@@ -452,7 +495,11 @@ def _resolve_explicit_runtime(
explicit_base_url
or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/")
)
api_key = explicit_api_key or str(state.get("agent_key") or state.get("access_token") or "").strip()
# Only use agent_key for inference — access_token is an OAuth token for the
# portal API (minting keys, refreshing tokens), not for the inference API.
# Falling back to access_token sends an OAuth bearer token to the inference
# endpoint, which returns 404 because it is not a valid inference credential.
api_key = explicit_api_key or str(state.get("agent_key") or "").strip()
expires_at = state.get("agent_key_expires_at") or state.get("expires_at")
if not api_key:
creds = resolve_nous_runtime_credentials(
@@ -666,14 +713,21 @@ def resolve_runtime_provider(
if provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", ""))
else:
# Check explicit api_mode from model config first
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
# Only honor persisted api_mode when it belongs to the same provider family.
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if configured_mode:
if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider):
api_mode = configured_mode
elif provider in ("opencode-zen", "opencode-go"):
from hermes_cli.models import opencode_model_api_mode
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
# Auto-detect Anthropic-compatible endpoints by URL convention
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
elif base_url.rstrip("/").endswith("/anthropic"):
api_mode = "anthropic_messages"
# Strip trailing /v1 for OpenCode Anthropic models (see comment above).
if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"):
base_url = re.sub(r"/v1/?$", "", base_url)
return {
"provider": provider,
"api_mode": api_mode,
+763 -549
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@ PLATFORMS = {
"dingtalk": "💬 DingTalk",
"feishu": "🪽 Feishu",
"wecom": "💬 WeCom",
"webhook": "🔗 Webhook",
}
# ─── Config Helpers ───────────────────────────────────────────────────────────
+27
View File
@@ -15,8 +15,10 @@ from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
from hermes_cli.models import provider_label
from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.runtime_provider import resolve_requested_provider
from hermes_constants import OPENROUTER_MODELS_URL
from tools.tool_backend_helpers import managed_nous_tools_enabled
def check_mark(ok: bool) -> str:
if ok:
@@ -186,6 +188,31 @@ def show_status(args):
if codex_status.get("error") and not codex_logged_in:
print(f" Error: {codex_status.get('error')}")
# =========================================================================
# Nous Subscription Features
# =========================================================================
if managed_nous_tools_enabled():
features = get_nous_subscription_features(config)
print()
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
if not features.nous_auth_present:
print(" Nous Portal ✗ not logged in")
else:
print(" Nous Portal ✓ managed tools available")
for feature in features.items():
if feature.managed_by_nous:
state = "active via Nous subscription"
elif feature.active:
current = feature.current_provider or "configured provider"
state = f"active via {current}"
elif feature.included_by_default and features.nous_auth_present:
state = "included by subscription, not currently selected"
elif feature.key == "modal" and features.nous_auth_present:
state = "available via subscription (optional)"
else:
state = "not configured"
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
# =========================================================================
# API-Key Providers
# =========================================================================
+176 -17
View File
@@ -20,6 +20,11 @@ from hermes_cli.config import (
load_config, save_config, get_env_value, save_env_value,
)
from hermes_cli.colors import Colors, color
from hermes_cli.nous_subscription import (
apply_nous_managed_defaults,
get_nous_subscription_features,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled
logger = logging.getLogger(__name__)
@@ -145,6 +150,7 @@ PLATFORMS = {
"wecom": {"label": "💬 WeCom", "default_toolset": "hermes-wecom"},
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
"webhook": {"label": "🔗 Webhook", "default_toolset": "hermes-webhook"},
}
@@ -158,6 +164,15 @@ TOOL_CATEGORIES = {
"name": "Text-to-Speech",
"icon": "🔊",
"providers": [
{
"name": "Nous Subscription",
"tag": "Managed OpenAI TTS billed to your subscription",
"env_vars": [],
"tts_provider": "openai",
"requires_nous_auth": True,
"managed_nous_feature": "tts",
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
},
{
"name": "Microsoft Edge TTS",
"tag": "Free - no API key needed",
@@ -188,6 +203,15 @@ TOOL_CATEGORIES = {
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
"icon": "🔍",
"providers": [
{
"name": "Nous Subscription",
"tag": "Managed Firecrawl billed to your subscription",
"web_backend": "firecrawl",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "web",
"override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"],
},
{
"name": "Firecrawl Cloud",
"tag": "Hosted service - search, extract, and crawl",
@@ -234,6 +258,14 @@ TOOL_CATEGORIES = {
"name": "Image Generation",
"icon": "🎨",
"providers": [
{
"name": "Nous Subscription",
"tag": "Managed FAL image generation billed to your subscription",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "image_gen",
"override_env_vars": ["FAL_KEY"],
},
{
"name": "FAL.ai",
"tag": "FLUX 2 Pro with auto-upscaling",
@@ -247,11 +279,21 @@ TOOL_CATEGORIES = {
"name": "Browser Automation",
"icon": "🌐",
"providers": [
{
"name": "Nous Subscription (Browserbase cloud)",
"tag": "Managed Browserbase billed to your subscription",
"env_vars": [],
"browser_provider": "browserbase",
"requires_nous_auth": True,
"managed_nous_feature": "browser",
"override_env_vars": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
"post_setup": "browserbase",
},
{
"name": "Local Browser",
"tag": "Free headless Chromium (no API key needed)",
"env_vars": [],
"browser_provider": None,
"browser_provider": "local",
"post_setup": "browserbase", # Same npm install for agent-browser
},
{
@@ -273,6 +315,15 @@ TOOL_CATEGORIES = {
"browser_provider": "browser-use",
"post_setup": "browserbase",
},
{
"name": "Firecrawl",
"tag": "Cloud browser with remote execution",
"env_vars": [
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
],
"browser_provider": "firecrawl",
"post_setup": "browserbase",
},
{
"name": "Camofox",
"tag": "Local anti-detection browser (Firefox/Camoufox)",
@@ -519,7 +570,7 @@ def _get_platform_tools(
# MCP servers are expected to be available on all platforms by default.
# If the platform explicitly lists one or more MCP server names, treat that
# as an allowlist. Otherwise include every globally enabled MCP server.
mcp_servers = config.get("mcp_servers", {})
mcp_servers = config.get("mcp_servers") or {}
enabled_mcp_servers = {
name
for name, server_cfg in mcp_servers.items()
@@ -581,8 +632,11 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
save_config(config)
def _toolset_has_keys(ts_key: str) -> bool:
def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
"""Check if a toolset's required API keys are configured."""
if config is None:
config = load_config()
if ts_key == "vision":
try:
from agent.auxiliary_client import resolve_vision_provider_client
@@ -592,10 +646,16 @@ def _toolset_has_keys(ts_key: str) -> bool:
except Exception:
return False
if ts_key in {"web", "image_gen", "tts", "browser"}:
features = get_nous_subscription_features(config)
feature = features.features.get(ts_key)
if feature and (feature.available or feature.managed_by_nous):
return True
# Check TOOL_CATEGORIES first (provider-aware)
cat = TOOL_CATEGORIES.get(ts_key)
if cat:
for provider in cat.get("providers", []):
for provider in _visible_providers(cat, config):
env_vars = provider.get("env_vars", [])
if not env_vars:
return True # No-key provider (e.g. Local Browser, Edge TTS)
@@ -805,11 +865,45 @@ def _configure_toolset(ts_key: str, config: dict):
_configure_simple_requirements(ts_key)
def _visible_providers(cat: dict, config: dict) -> list[dict]:
"""Return provider entries visible for the current auth/config state."""
features = get_nous_subscription_features(config)
visible = []
for provider in cat.get("providers", []):
if provider.get("managed_nous_feature") and not managed_nous_tools_enabled():
continue
if provider.get("requires_nous_auth") and not features.nous_auth_present:
continue
visible.append(provider)
return visible
def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
"""Return True when enabling this toolset should open provider setup."""
cat = TOOL_CATEGORIES.get(ts_key)
if not cat:
return not _toolset_has_keys(ts_key, config)
if ts_key == "tts":
tts_cfg = config.get("tts", {})
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
if ts_key == "web":
web_cfg = config.get("web", {})
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
if ts_key == "browser":
browser_cfg = config.get("browser", {})
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
if ts_key == "image_gen":
return not get_env_value("FAL_KEY")
return not _toolset_has_keys(ts_key, config)
def _configure_tool_category(ts_key: str, cat: dict, config: dict):
"""Configure a tool category with provider selection."""
icon = cat.get("icon", "")
name = cat["name"]
providers = cat["providers"]
providers = _visible_providers(cat, config)
# Check Python version requirement
if cat.get("requires_python"):
@@ -874,6 +968,27 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
def _is_provider_active(provider: dict, config: dict) -> bool:
"""Check if a provider entry matches the currently active config."""
managed_feature = provider.get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config)
feature = features.features.get(managed_feature)
if feature is None:
return False
if managed_feature == "image_gen":
return feature.managed_by_nous
if provider.get("tts_provider"):
return (
feature.managed_by_nous
and config.get("tts", {}).get("provider") == provider["tts_provider"]
)
if "browser_provider" in provider:
current = config.get("browser", {}).get("cloud_provider")
return feature.managed_by_nous and provider["browser_provider"] == current
if provider.get("web_backend"):
current = config.get("web", {}).get("backend")
return feature.managed_by_nous and current == provider["web_backend"]
return feature.managed_by_nous
if provider.get("tts_provider"):
return config.get("tts", {}).get("provider") == provider["tts_provider"]
if "browser_provider" in provider:
@@ -900,6 +1015,13 @@ def _detect_active_provider_index(providers: list, config: dict) -> int:
def _configure_provider(provider: dict, config: dict):
"""Configure a single provider - prompt for API keys and set config."""
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
features = get_nous_subscription_features(config)
if not features.nous_auth_present:
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
return
# Set TTS provider in config if applicable
if provider.get("tts_provider"):
@@ -908,11 +1030,12 @@ def _configure_provider(provider: dict, config: dict):
# Set browser cloud provider in config if applicable
if "browser_provider" in provider:
bp = provider["browser_provider"]
if bp:
if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}")
else:
config.get("browser", {}).pop("cloud_provider", None)
# Set web search backend in config if applicable
if provider.get("web_backend"):
@@ -920,7 +1043,16 @@ def _configure_provider(provider: dict, config: dict):
_print_success(f" Web backend set to: {provider['web_backend']}")
if not env_vars:
if provider.get("post_setup"):
_run_post_setup(provider["post_setup"])
_print_success(f" {provider['name']} - no configuration needed!")
if managed_feature:
_print_info(" Requests for this tool will be billed to your Nous subscription.")
override_envs = provider.get("override_env_vars", [])
if any(get_env_value(env_var) for env_var in override_envs):
_print_warning(
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
)
return
# Prompt for each required env var
@@ -1028,7 +1160,7 @@ def _reconfigure_tool(config: dict):
cat = TOOL_CATEGORIES.get(ts_key)
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
if cat or reqs:
if _toolset_has_keys(ts_key):
if _toolset_has_keys(ts_key, config):
configurable.append((ts_key, ts_label))
if not configurable:
@@ -1058,7 +1190,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
"""Reconfigure a tool category - provider selection + API key update."""
icon = cat.get("icon", "")
name = cat["name"]
providers = cat["providers"]
providers = _visible_providers(cat, config)
if len(providers) == 1:
provider = providers[0]
@@ -1093,6 +1225,13 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
def _reconfigure_provider(provider: dict, config: dict):
"""Reconfigure a provider - update API keys."""
env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
features = get_nous_subscription_features(config)
if not features.nous_auth_present:
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
return
if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
@@ -1100,12 +1239,12 @@ def _reconfigure_provider(provider: dict, config: dict):
if "browser_provider" in provider:
bp = provider["browser_provider"]
if bp:
if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}")
else:
config.get("browser", {}).pop("cloud_provider", None)
_print_success(" Browser set to local mode")
# Set web search backend in config if applicable
if provider.get("web_backend"):
@@ -1113,7 +1252,16 @@ def _reconfigure_provider(provider: dict, config: dict):
_print_success(f" Web backend set to: {provider['web_backend']}")
if not env_vars:
if provider.get("post_setup"):
_run_post_setup(provider["post_setup"])
_print_success(f" {provider['name']} - no configuration needed!")
if managed_feature:
_print_info(" Requests for this tool will be billed to your Nous subscription.")
override_envs = provider.get("override_env_vars", [])
if any(get_env_value(env_var) for env_var in override_envs):
_print_warning(
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
)
return
for var in env_vars:
@@ -1197,6 +1345,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD))
print(color(" Enable or disable tools per platform.", Colors.DIM))
print(color(" Tools that need API keys will be configured when enabled.", Colors.DIM))
print(color(" Guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/tools", Colors.DIM))
print()
# ── First-time install: linear flow, no platform menu ──
@@ -1222,13 +1371,23 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" - {label}", Colors.RED))
auto_configured = apply_nous_managed_defaults(
config,
enabled_toolsets=new_enabled,
)
if managed_nous_tools_enabled():
for ts_key in sorted(auto_configured):
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
print(color(f"{label}: using your Nous subscription defaults", Colors.GREEN))
# Walk through ALL selected tools that have provider options or
# need API keys. This ensures browser (Local vs Browserbase),
# TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when
# a free provider exists.
to_configure = [
ts_key for ts_key in sorted(new_enabled)
if TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key))
and ts_key not in auto_configured
]
if to_configure:
@@ -1321,7 +1480,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
# Configure API keys for newly enabled tools
for ts_key in sorted(added):
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
if not _toolset_has_keys(ts_key):
if _toolset_needs_configuration_prompt(ts_key, config):
_configure_toolset(ts_key, config)
_save_platform_tools(config, pk, new_enabled)
save_config(config)
@@ -1361,7 +1520,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
# Configure newly enabled toolsets that need API keys
for ts_key in sorted(added):
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
if not _toolset_has_keys(ts_key):
if _toolset_needs_configuration_prompt(ts_key, config):
_configure_toolset(ts_key, config)
_save_platform_tools(config, pkey, new_enabled)
+230
View File
@@ -0,0 +1,230 @@
"""Centralized logging setup for Hermes Agent.
Provides a single ``setup_logging()`` entry point that both the CLI and
gateway call early in their startup path. All log files live under
``~/.hermes/logs/`` (profile-aware via ``get_hermes_home()``).
Log files produced:
agent.log INFO+, all agent/tool/session activity (the main log)
errors.log WARNING+, errors and warnings only (quick triage)
Both files use ``RotatingFileHandler`` with ``RedactingFormatter`` so
secrets are never written to disk.
"""
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_home
# Sentinel to track whether setup_logging() has already run. The function
# is idempotent — calling it twice is safe but the second call is a no-op
# unless ``force=True``.
_logging_initialized = False
# Default log format — includes timestamp, level, logger name, and message.
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
_LOG_FORMAT_VERBOSE = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Third-party loggers that are noisy at DEBUG/INFO level.
_NOISY_LOGGERS = (
"openai",
"openai._base_client",
"httpx",
"httpcore",
"asyncio",
"hpack",
"hpack.hpack",
"grpc",
"modal",
"urllib3",
"urllib3.connectionpool",
"websockets",
"charset_normalizer",
"markdown_it",
)
def setup_logging(
*,
hermes_home: Optional[Path] = None,
log_level: Optional[str] = None,
max_size_mb: Optional[int] = None,
backup_count: Optional[int] = None,
mode: Optional[str] = None,
force: bool = False,
) -> Path:
"""Configure the Hermes logging subsystem.
Safe to call multiple times the second call is a no-op unless
*force* is ``True``.
Parameters
----------
hermes_home
Override for the Hermes home directory. Falls back to
``get_hermes_home()`` (profile-aware).
log_level
Minimum level for the ``agent.log`` file handler. Accepts any
standard Python level name (``"DEBUG"``, ``"INFO"``, ``"WARNING"``).
Defaults to ``"INFO"`` or the value from config.yaml ``logging.level``.
max_size_mb
Maximum size of each log file in megabytes before rotation.
Defaults to 5 or the value from config.yaml ``logging.max_size_mb``.
backup_count
Number of rotated backup files to keep.
Defaults to 3 or the value from config.yaml ``logging.backup_count``.
mode
Hint for the caller context: ``"cli"``, ``"gateway"``, ``"cron"``.
Currently used only for log format tuning (gateway includes PID).
force
Re-run setup even if it has already been called.
Returns
-------
Path
The ``logs/`` directory where files are written.
"""
global _logging_initialized
if _logging_initialized and not force:
home = hermes_home or get_hermes_home()
return home / "logs"
home = hermes_home or get_hermes_home()
log_dir = home / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
# Read config defaults (best-effort — config may not be loaded yet).
cfg_level, cfg_max_size, cfg_backup = _read_logging_config()
level_name = (log_level or cfg_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
max_bytes = (max_size_mb or cfg_max_size or 5) * 1024 * 1024
backups = backup_count or cfg_backup or 3
# Lazy import to avoid circular dependency at module load time.
from agent.redact import RedactingFormatter
root = logging.getLogger()
# --- agent.log (INFO+) — the main activity log -------------------------
_add_rotating_handler(
root,
log_dir / "agent.log",
level=level,
max_bytes=max_bytes,
backup_count=backups,
formatter=RedactingFormatter(_LOG_FORMAT),
)
# --- errors.log (WARNING+) — quick triage log --------------------------
_add_rotating_handler(
root,
log_dir / "errors.log",
level=logging.WARNING,
max_bytes=2 * 1024 * 1024,
backup_count=2,
formatter=RedactingFormatter(_LOG_FORMAT),
)
# Ensure root logger level is low enough for the handlers to fire.
if root.level == logging.NOTSET or root.level > level:
root.setLevel(level)
# Suppress noisy third-party loggers.
for name in _NOISY_LOGGERS:
logging.getLogger(name).setLevel(logging.WARNING)
_logging_initialized = True
return log_dir
def setup_verbose_logging() -> None:
"""Enable DEBUG-level console logging for ``--verbose`` / ``-v`` mode.
Called by ``AIAgent.__init__()`` when ``verbose_logging=True``.
"""
from agent.redact import RedactingFormatter
root = logging.getLogger()
# Avoid adding duplicate stream handlers.
for h in root.handlers:
if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler):
if getattr(h, "_hermes_verbose", False):
return
handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
handler.setFormatter(RedactingFormatter(_LOG_FORMAT_VERBOSE, datefmt="%H:%M:%S"))
handler._hermes_verbose = True # type: ignore[attr-defined]
root.addHandler(handler)
# Lower root logger level so DEBUG records reach all handlers.
if root.level > logging.DEBUG:
root.setLevel(logging.DEBUG)
# Keep third-party libraries at WARNING to reduce noise.
for name in _NOISY_LOGGERS:
logging.getLogger(name).setLevel(logging.WARNING)
# rex-deploy at INFO for sandbox status.
logging.getLogger("rex-deploy").setLevel(logging.INFO)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _add_rotating_handler(
logger: logging.Logger,
path: Path,
*,
level: int,
max_bytes: int,
backup_count: int,
formatter: logging.Formatter,
) -> None:
"""Add a ``RotatingFileHandler`` to *logger*, skipping if one already
exists for the same resolved file path (idempotent).
"""
resolved = path.resolve()
for existing in logger.handlers:
if (
isinstance(existing, RotatingFileHandler)
and Path(getattr(existing, "baseFilename", "")).resolve() == resolved
):
return # already attached
path.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
str(path), maxBytes=max_bytes, backupCount=backup_count,
)
handler.setLevel(level)
handler.setFormatter(formatter)
logger.addHandler(handler)
def _read_logging_config():
"""Best-effort read of ``logging.*`` from config.yaml.
Returns ``(level, max_size_mb, backup_count)`` any may be ``None``.
"""
try:
import yaml
config_path = get_hermes_home() / "config.yaml"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
log_cfg = cfg.get("logging", {})
if isinstance(log_cfg, dict):
return (
log_cfg.get("level"),
log_cfg.get("max_size_mb"),
log_cfg.get("backup_count"),
)
except Exception:
pass
return (None, None, None)
+50 -19
View File
@@ -349,13 +349,6 @@ class SessionDB:
self._conn.commit()
def close(self):
"""Close the database connection."""
with self._lock:
if self._conn:
self._conn.close()
self._conn = None
# =========================================================================
# Session lifecycle
# =========================================================================
@@ -794,6 +787,7 @@ class SessionDB:
exclude_sources: List[str] = None,
limit: int = 20,
offset: int = 0,
include_children: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -802,10 +796,16 @@ class SessionDB:
last_active (timestamp of last message).
Uses a single query with correlated subqueries instead of N+2 queries.
By default, child sessions (subagent runs, compression continuations)
are excluded. Pass ``include_children=True`` to include them.
"""
where_clauses = []
params = []
if not include_children:
where_clauses.append("s.parent_session_id IS NULL")
if source:
where_clauses.append("s.source = ?")
params.append(source)
@@ -1009,8 +1009,9 @@ class SessionDB:
Strategy:
- Preserve properly paired quoted phrases (``"exact phrase"``)
- Strip unmatched FTS5-special characters that would cause errors
- Wrap unquoted hyphenated terms in quotes so FTS5 matches them
as exact phrases instead of splitting on the hyphen
- Wrap unquoted hyphenated and dotted terms in quotes so FTS5
matches them as exact phrases instead of splitting on the
hyphen/dot (e.g. ``chat-send``, ``P2.2``, ``my-app.config.ts``)
"""
# Step 1: Extract balanced double-quoted phrases and protect them
# from further processing via numbered placeholders.
@@ -1035,11 +1036,13 @@ class SessionDB:
sanitized = re.sub(r"(?i)^(AND|OR|NOT)\b\s*", "", sanitized.strip())
sanitized = re.sub(r"(?i)\s+(AND|OR|NOT)\s*$", "", sanitized.strip())
# Step 5: Wrap unquoted hyphenated terms (e.g. ``chat-send``) in
# double quotes. FTS5's tokenizer splits on hyphens, turning
# ``chat-send`` into ``chat AND send``. Quoting preserves the
# intended phrase match.
sanitized = re.sub(r"\b(\w+(?:-\w+)+)\b", r'"\1"', sanitized)
# Step 5: Wrap unquoted dotted and/or hyphenated terms in double
# quotes. FTS5's tokenizer splits on dots and hyphens, turning
# ``chat-send`` into ``chat AND send`` and ``P2.2`` into ``p2 AND 2``.
# Quoting preserves phrase semantics. A single pass avoids the
# double-quoting bug that would occur if dotted and hyphenated
# patterns were applied sequentially (e.g. ``my-app.config``).
sanitized = re.sub(r"\b(\w+(?:[.-]\w+)+)\b", r'"\1"', sanitized)
# Step 6: Restore preserved quoted phrases
for i, quoted in enumerate(_quoted_parts):
@@ -1233,22 +1236,38 @@ class SessionDB:
self._execute_write(_do)
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages. Returns True if found."""
"""Delete a session, its child sessions, and all their messages.
Child sessions (subagent runs, compression continuations) are deleted
first to satisfy the ``parent_session_id`` foreign key constraint.
Returns True if the session was found and deleted.
"""
def _do(conn):
cursor = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
)
if cursor.fetchone()[0] == 0:
return False
# Delete child sessions first (FK constraint)
child_ids = [r[0] for r in conn.execute(
"SELECT id FROM sessions WHERE parent_session_id = ?",
(session_id,),
).fetchall()]
for cid in child_ids:
conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (cid,))
# Delete the session itself
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
return True
return self._execute_write(_do)
def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
"""
Delete sessions older than N days. Returns count of deleted sessions.
Only prunes ended sessions (not active ones).
"""Delete sessions older than N days. Returns count of deleted sessions.
Only prunes ended sessions (not active ones). Child sessions whose
parents are being pruned are deleted first to satisfy the
``parent_session_id`` foreign key constraint.
"""
cutoff = time.time() - (older_than_days * 86400)
@@ -1264,7 +1283,19 @@ class SessionDB:
"SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL",
(cutoff,),
)
session_ids = [row["id"] for row in cursor.fetchall()]
session_ids = set(row["id"] for row in cursor.fetchall())
# Delete children first whose parents are in the prune set
# (avoids FK constraint errors)
for sid in list(session_ids):
child_ids = [r[0] for r in conn.execute(
"SELECT id FROM sessions WHERE parent_session_id = ?",
(sid,),
).fetchall()]
for cid in child_ids:
conn.execute("DELETE FROM messages WHERE session_id = ?", (cid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (cid,))
session_ids.discard(cid) # don't double-delete
for sid in session_ids:
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
-9
View File
@@ -1,9 +0,0 @@
"""Honcho integration for AI-native memory.
This package is only active when honcho.enabled=true in config and
HONCHO_API_KEY is set. All honcho-ai imports are deferred to avoid
ImportError when the package is not installed.
Named ``honcho_integration`` (not ``honcho``) to avoid shadowing the
``honcho`` package installed by the ``honcho-ai`` SDK.
"""
+114 -9
View File
@@ -156,7 +156,7 @@ def _discover_tools():
"tools.delegate_tool",
"tools.process_registry",
"tools.send_message_tool",
"tools.honcho_tools",
# "tools.honcho_tools", # Removed — Honcho is now a memory provider plugin
"tools.homeassistant_tool",
]
import importlib
@@ -365,14 +365,105 @@ _AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
# =========================================================================
# Tool argument type coercion
# =========================================================================
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Coerce tool call arguments to match their JSON Schema types.
LLMs frequently return numbers as strings (``"42"`` instead of ``42``)
and booleans as strings (``"true"`` instead of ``true``). This compares
each argument value against the tool's registered JSON Schema and attempts
safe coercion when the value is a string but the schema expects a different
type. Original values are preserved when coercion fails.
Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``,
and union types (``"type": ["integer", "string"]``).
"""
if not args or not isinstance(args, dict):
return args
schema = registry.get_schema(tool_name)
if not schema:
return args
properties = (schema.get("parameters") or {}).get("properties")
if not properties:
return args
for key, value in args.items():
if not isinstance(value, str):
continue
prop_schema = properties.get(key)
if not prop_schema:
continue
expected = prop_schema.get("type")
if not expected:
continue
coerced = _coerce_value(value, expected)
if coerced is not value:
args[key] = coerced
return args
def _coerce_value(value: str, expected_type):
"""Attempt to coerce a string *value* to *expected_type*.
Returns the original string when coercion is not applicable or fails.
"""
if isinstance(expected_type, list):
# Union type — try each in order, return first successful coercion
for t in expected_type:
result = _coerce_value(value, t)
if result is not value:
return result
return value
if expected_type in ("integer", "number"):
return _coerce_number(value, integer_only=(expected_type == "integer"))
if expected_type == "boolean":
return _coerce_boolean(value)
return value
def _coerce_number(value: str, integer_only: bool = False):
"""Try to parse *value* as a number. Returns original string on failure."""
try:
f = float(value)
except (ValueError, OverflowError):
return value
# Guard against inf/nan before int() conversion
if f != f or f == float("inf") or f == float("-inf"):
return f
# If it looks like an integer (no fractional part), return int
if f == int(f):
return int(f)
if integer_only:
# Schema wants an integer but value has decimals — keep as string
return value
return f
def _coerce_boolean(value: str):
"""Try to parse *value* as a boolean. Returns original string on failure."""
low = value.strip().lower()
if low == "true":
return True
if low == "false":
return False
return value
def handle_function_call(
function_name: str,
function_args: Dict[str, Any],
task_id: Optional[str] = None,
tool_call_id: Optional[str] = None,
session_id: Optional[str] = None,
user_task: Optional[str] = None,
enabled_tools: Optional[List[str]] = None,
honcho_manager: Optional[Any] = None,
honcho_session_key: Optional[str] = None,
) -> str:
"""
Main function call dispatcher that routes calls to the tool registry.
@@ -390,6 +481,9 @@ def handle_function_call(
Returns:
Function result as a JSON string.
"""
# Coerce string arguments to their schema-declared types (e.g. "42"→42)
function_args = coerce_tool_args(function_name, function_args)
# Notify the read-loop tracker when a non-read/search tool runs,
# so the *consecutive* counter resets (reads after other work are fine).
if function_name not in _READ_SEARCH_TOOLS:
@@ -405,7 +499,14 @@ def handle_function_call(
try:
from hermes_cli.plugins import invoke_hook
invoke_hook("pre_tool_call", tool_name=function_name, args=function_args, task_id=task_id or "")
invoke_hook(
"pre_tool_call",
tool_name=function_name,
args=function_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
except Exception:
pass
@@ -417,21 +518,25 @@ def handle_function_call(
function_name, function_args,
task_id=task_id,
enabled_tools=sandbox_enabled,
honcho_manager=honcho_manager,
honcho_session_key=honcho_session_key,
)
else:
result = registry.dispatch(
function_name, function_args,
task_id=task_id,
user_task=user_task,
honcho_manager=honcho_manager,
honcho_session_key=honcho_session_key,
)
try:
from hermes_cli.plugins import invoke_hook
invoke_hook("post_tool_call", tool_name=function_name, args=function_args, result=result, task_id=task_id or "")
invoke_hook(
"post_tool_call",
tool_name=function_name,
args=function_args,
result=result,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
except Exception:
pass
+1 -1
View File
@@ -561,7 +561,7 @@
# ── Activation: link config + auth + documents ────────────────────
{
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" "setupSecrets" ] ''
# Ensure directories exist (activation runs before tmpfiles)
mkdir -p ${cfg.stateDir}/.hermes
mkdir -p ${cfg.stateDir}/home
+1 -1
View File
@@ -21,7 +21,7 @@
in {
packages.default = pkgs.stdenv.mkDerivation {
pname = "hermes-agent";
version = "0.1.0";
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
dontUnpack = true;
dontBuild = true;
@@ -0,0 +1,243 @@
---
name: honcho
description: Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, and dialectic reasoning. Use when setting up Honcho, troubleshooting memory, managing profiles with Honcho peers, or tuning observation and recall settings.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Honcho, Memory, Profiles, Observation, Dialectic, User-Modeling]
homepage: https://docs.honcho.dev
related_skills: [hermes-agent]
prerequisites:
pip: [honcho-ai]
---
# Honcho Memory for Hermes
Honcho provides AI-native cross-session user modeling. It learns who the user is across conversations and gives every Hermes profile its own peer identity while sharing a unified view of the user.
## When to Use
- Setting up Honcho (cloud or self-hosted)
- Troubleshooting memory not working / peers not syncing
- Creating multi-profile setups where each agent has its own Honcho peer
- Tuning observation, recall, or write frequency settings
- Understanding what the 4 Honcho tools do and when to use them
## Setup
### Cloud (app.honcho.dev)
```bash
hermes honcho setup
# select "cloud", paste API key from https://app.honcho.dev
```
### Self-hosted
```bash
hermes honcho setup
# select "local", enter base URL (e.g. http://localhost:8000)
```
See: https://docs.honcho.dev/v3/guides/integrations/hermes#running-honcho-locally-with-hermes
### Verify
```bash
hermes honcho status # shows resolved config, connection test, peer info
```
## Architecture
### Peers
Honcho models conversations as interactions between **peers**. Hermes creates two peers per session:
- **User peer** (`peerName`): represents the human. Honcho builds a user representation from observed messages.
- **AI peer** (`aiPeer`): represents this Hermes instance. Each profile gets its own AI peer so agents develop independent views.
### Observation
Each peer has two observation toggles that control what Honcho learns from:
| Toggle | What it does |
|--------|-------------|
| `observeMe` | Peer's own messages are observed (builds self-representation) |
| `observeOthers` | Other peers' messages are observed (builds cross-peer understanding) |
Default: all four toggles **on** (full bidirectional observation).
Configure per-peer in `honcho.json`:
```json
{
"observation": {
"user": { "observeMe": true, "observeOthers": true },
"ai": { "observeMe": true, "observeOthers": true }
}
}
```
Or use the shorthand presets:
| Preset | User | AI | Use case |
|--------|------|----|----------|
| `"directional"` (default) | me:on, others:on | me:on, others:on | Multi-agent, full memory |
| `"unified"` | me:on, others:off | me:off, others:on | Single agent, user-only modeling |
Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced back on session init -- server-side config wins over local defaults.
### Sessions
Honcho sessions scope where messages and observations land. Strategy options:
| Strategy | Behavior |
|----------|----------|
| `per-directory` (default) | One session per working directory |
| `per-repo` | One session per git repository root |
| `per-session` | New Honcho session each Hermes run |
| `global` | Single session across all directories |
Manual override: `hermes honcho map my-project-name`
### Recall Modes
How the agent accesses Honcho memory:
| Mode | Auto-inject context? | Tools available? | Use case |
|------|---------------------|-----------------|----------|
| `hybrid` (default) | Yes | Yes | Agent decides when to use tools vs auto context |
| `context` | Yes | No (hidden) | Minimal token cost, no tool calls |
| `tools` | No | Yes | Agent controls all memory access explicitly |
## Multi-Profile Setup
Each Hermes profile gets its own Honcho AI peer while sharing the same workspace (user context). This means:
- All profiles see the same user representation
- Each profile builds its own AI identity and observations
- Conclusions written by one profile are visible to others via the shared workspace
### Create a profile with Honcho peer
```bash
hermes profile create coder --clone
# creates host block hermes.coder, AI peer "coder", inherits config from default
```
What `--clone` does for Honcho:
1. Creates a `hermes.coder` host block in `honcho.json`
2. Sets `aiPeer: "coder"` (the profile name)
3. Inherits `workspace`, `peerName`, `writeFrequency`, `recallMode`, etc. from default
4. Eagerly creates the peer in Honcho so it exists before first message
### Backfill existing profiles
```bash
hermes honcho sync # creates host blocks for all profiles that don't have one yet
```
### Per-profile config
Override any setting in the host block:
```json
{
"hosts": {
"hermes.coder": {
"aiPeer": "coder",
"recallMode": "tools",
"observation": {
"user": { "observeMe": true, "observeOthers": false },
"ai": { "observeMe": true, "observeOthers": true }
}
}
}
}
```
## Tools
The agent has 4 Honcho tools (hidden in `context` recall mode):
### `honcho_profile`
Quick factual snapshot of the user -- name, role, preferences, patterns. No LLM call, minimal cost. Use at conversation start or for fast lookups.
### `honcho_search`
Semantic search over stored context. Returns raw excerpts ranked by relevance, no LLM synthesis. Default 800 tokens, max 2000. Use when you want specific past facts to reason over yourself.
### `honcho_context`
Natural language question answered by Honcho's dialectic reasoning (LLM call on Honcho's backend). Higher cost, higher quality. Can query about user (default) or the AI peer.
### `honcho_conclude`
Write a persistent fact about the user. Conclusions build the user's profile over time. Use when the user states a preference, corrects you, or shares something to remember.
## Config Reference
Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global).
### Key settings
| Key | Default | Description |
|-----|---------|-------------|
| `apiKey` | -- | API key ([get one](https://app.honcho.dev)) |
| `baseUrl` | -- | Base URL for self-hosted Honcho |
| `peerName` | -- | User peer identity |
| `aiPeer` | host key | AI peer identity |
| `workspace` | host key | Shared workspace ID |
| `recallMode` | `hybrid` | `hybrid`, `context`, or `tools` |
| `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans |
| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N |
| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` |
| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` |
| `dialecticDynamic` | `true` | Auto-bump reasoning by query length. `false` = fixed level |
| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) |
| `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input |
### Cost-awareness (advanced, root config only)
| Key | Default | Description |
|-----|---------|-------------|
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` |
| `contextCadence` | `1` | Min turns between context API calls |
| `dialecticCadence` | `1` | Min turns between dialectic API calls |
## Troubleshooting
### "Honcho not configured"
Run `hermes honcho setup`. Ensure `memory.provider: honcho` is in `~/.hermes/config.yaml`.
### Memory not persisting across sessions
Check `hermes honcho status` -- verify `saveMessages: true` and `writeFrequency` isn't `session` (which only writes on exit).
### Profile not getting its own peer
Use `--clone` when creating: `hermes profile create <name> --clone`. For existing profiles: `hermes honcho sync`.
### Observation changes in dashboard not reflected
Observation config is synced from the server on each session init. Start a new session after changing settings in the Honcho UI.
### Messages truncated
Messages over `messageMaxChars` (default 25k) are automatically chunked with `[continued]` markers. If you're hitting this often, check if tool results or skill content is inflating message size.
## CLI Commands
| Command | Description |
|---------|-------------|
| `hermes honcho setup` | Interactive setup wizard (cloud/local, identity, observation, recall, sessions) |
| `hermes honcho status` | Show resolved config, connection test, peer info for active profile |
| `hermes honcho enable` | Enable Honcho for the active profile (creates host block if needed) |
| `hermes honcho disable` | Disable Honcho for the active profile |
| `hermes honcho peer` | Show or update peer names (`--user <name>`, `--ai <name>`, `--reasoning <level>`) |
| `hermes honcho peers` | Show peer identities across all profiles |
| `hermes honcho mode` | Show or set recall mode (`hybrid`, `context`, `tools`) |
| `hermes honcho tokens` | Show or set token budgets (`--context <N>`, `--dialectic <N>`) |
| `hermes honcho sessions` | List known directory-to-session-name mappings |
| `hermes honcho map <name>` | Map current working directory to a Honcho session name |
| `hermes honcho identity` | Seed AI peer identity or show both peer representations |
| `hermes honcho sync` | Create host blocks for all Hermes profiles that don't have one yet |
| `hermes honcho migrate` | Step-by-step migration guide from OpenClaw native memory to Hermes + Honcho |
| `hermes memory setup` | Generic memory provider picker (selecting "honcho" runs the same wizard) |
| `hermes memory status` | Show active memory provider and config |
| `hermes memory off` | Disable external memory provider |
@@ -0,0 +1,213 @@
---
name: gitnexus-explorer
description: Index a codebase with GitNexus and serve an interactive knowledge graph via web UI + Cloudflare tunnel.
version: 1.0.0
author: Hermes Agent + Teknium
license: MIT
metadata:
hermes:
tags: [gitnexus, code-intelligence, knowledge-graph, visualization]
related_skills: [native-mcp, codebase-inspection]
---
# GitNexus Explorer
Index any codebase into a knowledge graph and serve an interactive web UI for exploring
symbols, call chains, clusters, and execution flows. Tunneled via Cloudflare for remote access.
## When to Use
- User wants to visually explore a codebase's architecture
- User asks for a knowledge graph / dependency graph of a repo
- User wants to share an interactive codebase explorer with someone
## Prerequisites
- **Node.js** (v18+) — required for GitNexus and the proxy
- **git** — repo must have a `.git` directory
- **cloudflared** — for tunneling (auto-installed to ~/.local/bin if missing)
## Size Warning
The web UI renders all nodes in the browser. Repos under ~5,000 files work well. Large
repos (30k+ nodes) will be sluggish or crash the browser tab. The CLI/MCP tools work
at any scale — only the web visualization has this limit.
## Steps
### 1. Clone and Build GitNexus (one-time setup)
```bash
GITNEXUS_DIR="${GITNEXUS_DIR:-$HOME/.local/share/gitnexus}"
if [ ! -d "$GITNEXUS_DIR/gitnexus-web/dist" ]; then
git clone https://github.com/abhigyanpatwari/GitNexus.git "$GITNEXUS_DIR"
cd "$GITNEXUS_DIR/gitnexus-shared" && npm install && npm run build
cd "$GITNEXUS_DIR/gitnexus-web" && npm install
fi
```
### 2. Patch the Web UI for Remote Access
The web UI defaults to `localhost:4747` for API calls. Patch it to use same-origin
so it works through a tunnel/proxy:
**File: `$GITNEXUS_DIR/gitnexus-web/src/config/ui-constants.ts`**
Change:
```typescript
export const DEFAULT_BACKEND_URL = 'http://localhost:4747';
```
To:
```typescript
export const DEFAULT_BACKEND_URL = typeof window !== 'undefined' && window.location.hostname !== 'localhost' ? window.location.origin : 'http://localhost:4747';
```
**File: `$GITNEXUS_DIR/gitnexus-web/vite.config.ts`**
Add `allowedHosts: true` inside the `server: { }` block (only needed if running dev
mode instead of production build):
```typescript
server: {
allowedHosts: true,
// ... existing config
},
```
Then build the production bundle:
```bash
cd "$GITNEXUS_DIR/gitnexus-web" && npx vite build
```
### 3. Index the Target Repo
```bash
cd /path/to/target-repo
npx gitnexus analyze --skip-agents-md
rm -rf .claude/ # remove Claude Code-specific artifacts
```
Add `--embeddings` for semantic search (slower — minutes instead of seconds).
The index lives in `.gitnexus/` inside the repo (auto-gitignored).
### 4. Create the Proxy Script
Write this to a file (e.g., `$GITNEXUS_DIR/proxy.mjs`). It serves the production
web UI and proxies `/api/*` to the GitNexus backend — same origin, no CORS issues,
no sudo, no nginx.
```javascript
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const API_PORT = parseInt(process.env.API_PORT || '4747');
const DIST_DIR = process.argv[2] || './dist';
const PORT = parseInt(process.argv[3] || '8888');
const MIME = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff',
'.wasm': 'application/wasm',
};
function proxyToApi(req, res) {
const opts = {
hostname: '127.0.0.1', port: API_PORT,
path: req.url, method: req.method, headers: req.headers,
};
const proxy = http.request(opts, (upstream) => {
res.writeHead(upstream.statusCode, upstream.headers);
upstream.pipe(res, { end: true });
});
proxy.on('error', () => { res.writeHead(502); res.end('Backend unavailable'); });
req.pipe(proxy, { end: true });
}
function serveStatic(req, res) {
let filePath = path.join(DIST_DIR, req.url === '/' ? 'index.html' : req.url.split('?')[0]);
if (!fs.existsSync(filePath)) filePath = path.join(DIST_DIR, 'index.html');
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const data = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=3600' });
res.end(data);
} catch { res.writeHead(404); res.end('Not found'); }
}
http.createServer((req, res) => {
if (req.url.startsWith('/api')) proxyToApi(req, res);
else serveStatic(req, res);
}).listen(PORT, () => console.log(`GitNexus proxy on http://localhost:${PORT}`));
```
### 5. Start the Services
```bash
# Terminal 1: GitNexus backend API
npx gitnexus serve &
# Terminal 2: Proxy (web UI + API on one port)
node "$GITNEXUS_DIR/proxy.mjs" "$GITNEXUS_DIR/gitnexus-web/dist" 8888 &
```
Verify: `curl -s http://localhost:8888/api/repos` should return the indexed repo(s).
### 6. Tunnel with Cloudflare (optional — for remote access)
```bash
# Install cloudflared if needed (no sudo)
if ! command -v cloudflared &>/dev/null; then
mkdir -p ~/.local/bin
curl -sL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
-o ~/.local/bin/cloudflared
chmod +x ~/.local/bin/cloudflared
export PATH="$HOME/.local/bin:$PATH"
fi
# Start tunnel (--config /dev/null avoids conflicts with existing named tunnels)
cloudflared tunnel --config /dev/null --url http://localhost:8888 --no-autoupdate --protocol http2
```
The tunnel URL (e.g., `https://random-words.trycloudflare.com`) is printed to stderr.
Share it — anyone with the link can explore the graph.
### 7. Cleanup
```bash
# Stop services
pkill -f "gitnexus serve"
pkill -f "proxy.mjs"
pkill -f cloudflared
# Remove index from the target repo
cd /path/to/target-repo
npx gitnexus clean
rm -rf .claude/
```
## Pitfalls
- **`--config /dev/null` is required for cloudflared** if the user has an existing
named tunnel config at `~/.cloudflared/config.yml`. Without it, the catch-all
ingress rule in the config returns 404 for all quick tunnel requests.
- **Production build is mandatory for tunneling.** The Vite dev server blocks
non-localhost hosts by default (`allowedHosts`). The production build + Node
proxy avoids this entirely.
- **The web UI does NOT create `.claude/` or `CLAUDE.md`.** Those are created by
`npx gitnexus analyze`. Use `--skip-agents-md` to suppress the markdown files,
then `rm -rf .claude/` for the rest. These are Claude Code integrations that
hermes-agent users don't need.
- **Browser memory limit.** The web UI loads the entire graph into browser memory.
Repos with 5k+ files may be sluggish. 30k+ files will likely crash the tab.
- **Embeddings are optional.** `--embeddings` enables semantic search but takes
minutes on large repos. Skip it for quick exploration; add it if you want
natural language queries via the AI chat panel.
- **Multiple repos.** `gitnexus serve` serves ALL indexed repos. Index several
repos, start serve once, and the web UI lets you switch between them.
@@ -0,0 +1,92 @@
/**
* GitNexus reverse proxy serves production web UI + proxies /api/* to backend.
* Zero dependencies, Node.js built-ins only.
*
* Usage: node proxy.mjs <dist-dir> [port]
* dist-dir: path to gitnexus-web/dist (production build)
* port: listen port (default: 8888)
*
* Environment:
* API_PORT: GitNexus serve backend port (default: 4747)
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const API_PORT = parseInt(process.env.API_PORT || '4747');
const DIST_DIR = process.argv[2] || './dist';
const PORT = parseInt(process.argv[3] || '8888');
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.wasm': 'application/wasm',
'.ttf': 'font/ttf',
'.map': 'application/json',
};
function proxyToApi(req, res) {
const opts = {
hostname: '127.0.0.1',
port: API_PORT,
path: req.url,
method: req.method,
headers: { ...req.headers, host: `127.0.0.1:${API_PORT}` },
};
const proxy = http.request(opts, (upstream) => {
res.writeHead(upstream.statusCode, upstream.headers);
upstream.pipe(res, { end: true });
});
proxy.on('error', () => {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('GitNexus backend unavailable — is `npx gitnexus serve` running?');
});
req.pipe(proxy, { end: true });
}
function serveStatic(req, res) {
const urlPath = req.url.split('?')[0];
let filePath = path.join(DIST_DIR, urlPath === '/' ? 'index.html' : urlPath);
// SPA fallback: if file doesn't exist and isn't a static asset, serve index.html
if (!fs.existsSync(filePath) && !path.extname(filePath)) {
filePath = path.join(DIST_DIR, 'index.html');
}
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const data = fs.readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mime,
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=86400',
});
res.end(data);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
}
}
const server = http.createServer((req, res) => {
if (req.url.startsWith('/api')) {
proxyToApi(req, res);
} else {
serveStatic(req, res);
}
});
server.listen(PORT, () => {
console.log(`GitNexus proxy listening on http://localhost:${PORT}`);
console.log(` Web UI: http://localhost:${PORT}/`);
console.log(` API: http://localhost:${PORT}/api/repos`);
console.log(` Backend: http://127.0.0.1:${API_PORT}`);
});
+1
View File
@@ -0,0 +1 @@
# Hermes plugins package
+317
View File
@@ -0,0 +1,317 @@
"""Memory provider plugin discovery.
Scans ``plugins/memory/<name>/`` directories for memory provider plugins.
Each subdirectory must contain ``__init__.py`` with a class implementing
the MemoryProvider ABC.
Memory providers are separate from the general plugin system they live
in the repo and are always available without user installation. Only ONE
can be active at a time, selected via ``memory.provider`` in config.yaml.
Usage:
from plugins.memory import discover_memory_providers, load_memory_provider
available = discover_memory_providers() # [(name, desc, available), ...]
provider = load_memory_provider("openviking") # MemoryProvider instance
"""
from __future__ import annotations
import importlib
import importlib.util
import logging
import sys
from pathlib import Path
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
_MEMORY_PLUGINS_DIR = Path(__file__).parent
def discover_memory_providers() -> List[Tuple[str, str, bool]]:
"""Scan plugins/memory/ for available providers.
Returns list of (name, description, is_available) tuples.
Does NOT import the providers just reads plugin.yaml for metadata
and does a lightweight availability check.
"""
results = []
if not _MEMORY_PLUGINS_DIR.is_dir():
return results
for child in sorted(_MEMORY_PLUGINS_DIR.iterdir()):
if not child.is_dir() or child.name.startswith(("_", ".")):
continue
init_file = child / "__init__.py"
if not init_file.exists():
continue
# Read description from plugin.yaml if available
desc = ""
yaml_file = child / "plugin.yaml"
if yaml_file.exists():
try:
import yaml
with open(yaml_file) as f:
meta = yaml.safe_load(f) or {}
desc = meta.get("description", "")
except Exception:
pass
# Quick availability check — try loading and calling is_available()
available = True
try:
provider = _load_provider_from_dir(child)
if provider:
available = provider.is_available()
else:
available = False
except Exception:
available = False
results.append((child.name, desc, available))
return results
def load_memory_provider(name: str) -> Optional["MemoryProvider"]:
"""Load and return a MemoryProvider instance by name.
Returns None if the provider is not found or fails to load.
"""
provider_dir = _MEMORY_PLUGINS_DIR / name
if not provider_dir.is_dir():
logger.debug("Memory provider '%s' not found in %s", name, _MEMORY_PLUGINS_DIR)
return None
try:
provider = _load_provider_from_dir(provider_dir)
if provider:
return provider
logger.warning("Memory provider '%s' loaded but no provider instance found", name)
return None
except Exception as e:
logger.warning("Failed to load memory provider '%s': %s", name, e)
return None
def _load_provider_from_dir(provider_dir: Path) -> Optional["MemoryProvider"]:
"""Import a provider module and extract the MemoryProvider instance.
The module must have either:
- A register(ctx) function (plugin-style) we simulate a ctx
- A top-level class that extends MemoryProvider we instantiate it
"""
name = provider_dir.name
module_name = f"plugins.memory.{name}"
init_file = provider_dir / "__init__.py"
if not init_file.exists():
return None
# Check if already loaded
if module_name in sys.modules:
mod = sys.modules[module_name]
else:
# Handle relative imports within the plugin
# First ensure the parent packages are registered
for parent in ("plugins", "plugins.memory"):
if parent not in sys.modules:
parent_path = Path(__file__).parent
if parent == "plugins":
parent_path = parent_path.parent
parent_init = parent_path / "__init__.py"
if parent_init.exists():
spec = importlib.util.spec_from_file_location(
parent, str(parent_init),
submodule_search_locations=[str(parent_path)]
)
if spec:
parent_mod = importlib.util.module_from_spec(spec)
sys.modules[parent] = parent_mod
try:
spec.loader.exec_module(parent_mod)
except Exception:
pass
# Now load the provider module
spec = importlib.util.spec_from_file_location(
module_name, str(init_file),
submodule_search_locations=[str(provider_dir)]
)
if not spec:
return None
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
# Register submodules so relative imports work
# e.g., "from .store import MemoryStore" in holographic plugin
for sub_file in provider_dir.glob("*.py"):
if sub_file.name == "__init__.py":
continue
sub_name = sub_file.stem
full_sub_name = f"{module_name}.{sub_name}"
if full_sub_name not in sys.modules:
sub_spec = importlib.util.spec_from_file_location(
full_sub_name, str(sub_file)
)
if sub_spec:
sub_mod = importlib.util.module_from_spec(sub_spec)
sys.modules[full_sub_name] = sub_mod
try:
sub_spec.loader.exec_module(sub_mod)
except Exception as e:
logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
try:
spec.loader.exec_module(mod)
except Exception as e:
logger.debug("Failed to exec_module %s: %s", module_name, e)
sys.modules.pop(module_name, None)
return None
# Try register(ctx) pattern first (how our plugins are written)
if hasattr(mod, "register"):
collector = _ProviderCollector()
try:
mod.register(collector)
if collector.provider:
return collector.provider
except Exception as e:
logger.debug("register() failed for %s: %s", name, e)
# Fallback: find a MemoryProvider subclass and instantiate it
from agent.memory_provider import MemoryProvider
for attr_name in dir(mod):
attr = getattr(mod, attr_name, None)
if (isinstance(attr, type) and issubclass(attr, MemoryProvider)
and attr is not MemoryProvider):
try:
return attr()
except Exception:
pass
return None
class _ProviderCollector:
"""Fake plugin context that captures register_memory_provider calls."""
def __init__(self):
self.provider = None
def register_memory_provider(self, provider):
self.provider = provider
# No-op for other registration methods
def register_tool(self, *args, **kwargs):
pass
def register_hook(self, *args, **kwargs):
pass
def register_cli_command(self, *args, **kwargs):
pass # CLI registration happens via discover_plugin_cli_commands()
def _get_active_memory_provider() -> Optional[str]:
"""Read the active memory provider name from config.yaml.
Returns the provider name (e.g. ``"honcho"``) or None if no
external provider is configured. Lightweight only reads config,
no plugin loading.
"""
try:
from hermes_cli.config import load_config
config = load_config()
return config.get("memory", {}).get("provider") or None
except Exception:
return None
def discover_plugin_cli_commands() -> List[dict]:
"""Return CLI commands for the **active** memory plugin only.
Only one memory provider can be active at a time (set via
``memory.provider`` in config.yaml). This function reads that
value and only loads CLI registration for the matching plugin.
If no provider is active, no commands are registered.
Looks for a ``register_cli(subparser)`` function in the active
plugin's ``cli.py``. Returns a list of at most one dict with
keys: ``name``, ``help``, ``description``, ``setup_fn``,
``handler_fn``.
This is a lightweight scan it only imports ``cli.py``, not the
full plugin module. Safe to call during argparse setup before
any provider is loaded.
"""
results: List[dict] = []
if not _MEMORY_PLUGINS_DIR.is_dir():
return results
active_provider = _get_active_memory_provider()
if not active_provider:
return results
# Only look at the active provider's directory
plugin_dir = _MEMORY_PLUGINS_DIR / active_provider
if not plugin_dir.is_dir():
return results
cli_file = plugin_dir / "cli.py"
if not cli_file.exists():
return results
module_name = f"plugins.memory.{active_provider}.cli"
try:
# Import the CLI module (lightweight — no SDK needed)
if module_name in sys.modules:
cli_mod = sys.modules[module_name]
else:
spec = importlib.util.spec_from_file_location(
module_name, str(cli_file)
)
if not spec or not spec.loader:
return results
cli_mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = cli_mod
spec.loader.exec_module(cli_mod)
register_cli = getattr(cli_mod, "register_cli", None)
if not callable(register_cli):
return results
# Read metadata from plugin.yaml if available
help_text = f"Manage {active_provider} memory plugin"
description = ""
yaml_file = plugin_dir / "plugin.yaml"
if yaml_file.exists():
try:
import yaml
with open(yaml_file) as f:
meta = yaml.safe_load(f) or {}
desc = meta.get("description", "")
if desc:
help_text = desc
description = desc
except Exception:
pass
handler_fn = getattr(cli_mod, f"{active_provider}_command", None) or \
getattr(cli_mod, "honcho_command", None)
results.append({
"name": active_provider,
"help": help_text,
"description": description,
"setup_fn": register_cli,
"handler_fn": handler_fn,
"plugin": active_provider,
})
except Exception as e:
logger.debug("Failed to scan CLI for memory plugin '%s': %s", active_provider, e)
return results
+41
View File
@@ -0,0 +1,41 @@
# ByteRover Memory Provider
Persistent memory via the `brv` CLI — hierarchical knowledge tree with tiered retrieval (fuzzy text → LLM-driven search).
## Requirements
Install the ByteRover CLI:
```bash
curl -fsSL https://byterover.dev/install.sh | sh
# or
npm install -g byterover-cli
```
## Setup
```bash
hermes memory setup # select "byterover"
```
Or manually:
```bash
hermes config set memory.provider byterover
# Optional cloud sync:
echo "BRV_API_KEY=your-key" >> ~/.hermes/.env
```
## Config
| Env Var | Required | Description |
|---------|----------|-------------|
| `BRV_API_KEY` | No | Cloud sync key (optional, local-first by default) |
Working directory: `$HERMES_HOME/byterover/` (profile-scoped).
## Tools
| Tool | Description |
|------|-------------|
| `brv_query` | Search the knowledge tree |
| `brv_curate` | Store facts, decisions, patterns |
| `brv_status` | CLI version, tree stats, sync state |
+383
View File
@@ -0,0 +1,383 @@
"""ByteRover memory plugin — MemoryProvider interface.
Persistent memory via the ByteRover CLI (``brv``). Organizes knowledge into
a hierarchical context tree with tiered retrieval (fuzzy text LLM-driven
search). Local-first with optional cloud sync.
Original PR #3499 by hieuntg81, adapted to MemoryProvider ABC.
Requires: ``brv`` CLI installed (npm install -g byterover-cli or
curl -fsSL https://byterover.dev/install.sh | sh).
Config via environment variables (profile-scoped via each profile's .env):
BRV_API_KEY ByteRover API key (for cloud features, optional for local)
Working directory: $HERMES_HOME/byterover/ (profile-scoped context tree)
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
# Timeouts
_QUERY_TIMEOUT = 10 # brv query — should be fast
_CURATE_TIMEOUT = 120 # brv curate — may involve LLM processing
# Minimum lengths to filter noise
_MIN_QUERY_LEN = 10
_MIN_OUTPUT_LEN = 20
# ---------------------------------------------------------------------------
# brv binary resolution (cached, thread-safe)
# ---------------------------------------------------------------------------
_brv_path_lock = threading.Lock()
_cached_brv_path: Optional[str] = None
def _resolve_brv_path() -> Optional[str]:
"""Find the brv binary on PATH or well-known install locations."""
global _cached_brv_path
with _brv_path_lock:
if _cached_brv_path is not None:
return _cached_brv_path if _cached_brv_path != "" else None
found = shutil.which("brv")
if not found:
home = Path.home()
candidates = [
home / ".brv-cli" / "bin" / "brv",
Path("/usr/local/bin/brv"),
home / ".npm-global" / "bin" / "brv",
]
for c in candidates:
if c.exists():
found = str(c)
break
with _brv_path_lock:
if _cached_brv_path is not None:
return _cached_brv_path if _cached_brv_path != "" else None
_cached_brv_path = found or ""
return found
def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
cwd: str = None) -> dict:
"""Run a brv CLI command. Returns {success, output, error}."""
brv_path = _resolve_brv_path()
if not brv_path:
return {"success": False, "error": "brv CLI not found. Install: npm install -g byterover-cli"}
cmd = [brv_path] + args
effective_cwd = cwd or str(_get_brv_cwd())
Path(effective_cwd).mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
brv_bin_dir = str(Path(brv_path).parent)
env["PATH"] = brv_bin_dir + os.pathsep + env.get("PATH", "")
try:
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, cwd=effective_cwd, env=env,
)
stdout = result.stdout.strip()
stderr = result.stderr.strip()
if result.returncode == 0:
return {"success": True, "output": stdout}
return {"success": False, "error": stderr or stdout or f"brv exited {result.returncode}"}
except subprocess.TimeoutExpired:
return {"success": False, "error": f"brv timed out after {timeout}s"}
except FileNotFoundError:
global _cached_brv_path
with _brv_path_lock:
_cached_brv_path = None
return {"success": False, "error": "brv CLI not found"}
except Exception as e:
return {"success": False, "error": str(e)}
def _get_brv_cwd() -> Path:
"""Profile-scoped working directory for the brv context tree."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "byterover"
# ---------------------------------------------------------------------------
# Tool schemas
# ---------------------------------------------------------------------------
QUERY_SCHEMA = {
"name": "brv_query",
"description": (
"Search ByteRover's persistent knowledge tree for relevant context. "
"Returns memories, project knowledge, architectural decisions, and "
"patterns from previous sessions. Use for any question where past "
"context would help."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for."},
},
"required": ["query"],
},
}
CURATE_SCHEMA = {
"name": "brv_curate",
"description": (
"Store important information in ByteRover's persistent knowledge tree. "
"Use for architectural decisions, bug fixes, user preferences, project "
"patterns — anything worth remembering across sessions. ByteRover's LLM "
"automatically categorizes and organizes the memory."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The information to remember."},
},
"required": ["content"],
},
}
STATUS_SCHEMA = {
"name": "brv_status",
"description": "Check ByteRover status — CLI version, context tree stats, cloud sync state.",
"parameters": {"type": "object", "properties": {}, "required": []},
}
# ---------------------------------------------------------------------------
# MemoryProvider implementation
# ---------------------------------------------------------------------------
class ByteRoverMemoryProvider(MemoryProvider):
"""ByteRover persistent memory via the brv CLI."""
def __init__(self):
self._cwd = ""
self._session_id = ""
self._turn_count = 0
self._sync_thread: Optional[threading.Thread] = None
@property
def name(self) -> str:
return "byterover"
def is_available(self) -> bool:
"""Check if brv CLI is installed. No network calls."""
return _resolve_brv_path() is not None
def get_config_schema(self):
return [
{
"key": "api_key",
"description": "ByteRover API key (optional, for cloud sync)",
"secret": True,
"env_var": "BRV_API_KEY",
"url": "https://app.byterover.dev",
},
]
def initialize(self, session_id: str, **kwargs) -> None:
self._cwd = str(_get_brv_cwd())
self._session_id = session_id
self._turn_count = 0
Path(self._cwd).mkdir(parents=True, exist_ok=True)
def system_prompt_block(self) -> str:
if not _resolve_brv_path():
return ""
return (
"# ByteRover Memory\n"
"Active. Persistent knowledge tree with hierarchical context.\n"
"Use brv_query to search past knowledge, brv_curate to store "
"important facts, brv_status to check state."
)
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Run brv query synchronously before the agent's first LLM call.
Blocks until the query completes (up to _QUERY_TIMEOUT seconds), ensuring
the result is available as context before the model is called.
"""
if not query or len(query.strip()) < _MIN_QUERY_LEN:
return ""
result = _run_brv(
["query", "--", query.strip()[:5000]],
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
)
if result["success"] and result.get("output"):
output = result["output"].strip()
if len(output) > _MIN_OUTPUT_LEN:
return f"## ByteRover Context\n{output}"
return ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""No-op: prefetch() now runs synchronously at turn start."""
pass
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Curate the conversation turn in background (non-blocking)."""
self._turn_count += 1
# Only curate substantive turns
if len(user_content.strip()) < _MIN_QUERY_LEN:
return
def _sync():
try:
combined = f"User: {user_content[:2000]}\nAssistant: {assistant_content[:2000]}"
_run_brv(
["curate", "--", combined],
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
)
except Exception as e:
logger.debug("ByteRover sync failed: %s", e)
# Wait for previous sync
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(
target=_sync, daemon=True, name="brv-sync"
)
self._sync_thread.start()
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Mirror built-in memory writes to ByteRover."""
if action not in ("add", "replace") or not content:
return
def _write():
try:
label = "User profile" if target == "user" else "Agent memory"
_run_brv(
["curate", "--", f"[{label}] {content}"],
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
)
except Exception as e:
logger.debug("ByteRover memory mirror failed: %s", e)
t = threading.Thread(target=_write, daemon=True, name="brv-memwrite")
t.start()
def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
"""Extract insights before context compression discards turns."""
if not messages:
return ""
# Build a summary of messages about to be compressed
parts = []
for msg in messages[-10:]: # last 10 messages
role = msg.get("role", "")
content = msg.get("content", "")
if isinstance(content, str) and content.strip() and role in ("user", "assistant"):
parts.append(f"{role}: {content[:500]}")
if not parts:
return ""
combined = "\n".join(parts)
def _flush():
try:
_run_brv(
["curate", "--", f"[Pre-compression context]\n{combined}"],
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
)
logger.info("ByteRover pre-compression flush: %d messages", len(parts))
except Exception as e:
logger.debug("ByteRover pre-compression flush failed: %s", e)
t = threading.Thread(target=_flush, daemon=True, name="brv-flush")
t.start()
return ""
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [QUERY_SCHEMA, CURATE_SCHEMA, STATUS_SCHEMA]
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
if tool_name == "brv_query":
return self._tool_query(args)
elif tool_name == "brv_curate":
return self._tool_curate(args)
elif tool_name == "brv_status":
return self._tool_status()
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def shutdown(self) -> None:
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=10.0)
# -- Tool implementations ------------------------------------------------
def _tool_query(self, args: dict) -> str:
query = args.get("query", "")
if not query:
return json.dumps({"error": "query is required"})
result = _run_brv(
["query", "--", query.strip()[:5000]],
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
)
if not result["success"]:
return json.dumps({"error": result.get("error", "Query failed")})
output = result.get("output", "").strip()
if not output or len(output) < _MIN_OUTPUT_LEN:
return json.dumps({"result": "No relevant memories found."})
# Truncate very long results
if len(output) > 8000:
output = output[:8000] + "\n\n[... truncated]"
return json.dumps({"result": output})
def _tool_curate(self, args: dict) -> str:
content = args.get("content", "")
if not content:
return json.dumps({"error": "content is required"})
result = _run_brv(
["curate", "--", content],
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
)
if not result["success"]:
return json.dumps({"error": result.get("error", "Curate failed")})
return json.dumps({"result": "Memory curated successfully."})
def _tool_status(self) -> str:
result = _run_brv(["status"], timeout=15, cwd=self._cwd)
if not result["success"]:
return json.dumps({"error": result.get("error", "Status check failed")})
return json.dumps({"status": result.get("output", "")})
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
def register(ctx) -> None:
"""Register ByteRover as a memory provider plugin."""
ctx.register_memory_provider(ByteRoverMemoryProvider())
+9
View File
@@ -0,0 +1,9 @@
name: byterover
version: 1.0.0
description: "ByteRover — persistent knowledge tree with tiered retrieval via the brv CLI."
external_dependencies:
- name: brv
install: "curl -fsSL https://byterover.dev/install.sh | sh"
check: "brv --version"
hooks:
- on_pre_compress
+98
View File
@@ -0,0 +1,98 @@
# Hindsight Memory Provider
Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Supports cloud and local (embedded) modes.
## Requirements
- **Cloud:** API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io)
- **Local:** API key for a supported LLM provider (OpenAI, Anthropic, Gemini, Groq, MiniMax, or Ollama). Embeddings and reranking run locally — no additional API keys needed.
## Setup
```bash
hermes memory setup # select "hindsight"
```
The setup wizard will install dependencies automatically via `uv` and walk you through configuration.
Or manually (cloud mode with defaults):
```bash
hermes config set memory.provider hindsight
echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env
```
### Cloud Mode
Connects to the Hindsight Cloud API. Requires an API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io).
### Local Mode
Runs an embedded Hindsight server with built-in PostgreSQL. Requires an LLM API key (e.g. Groq, OpenAI, Anthropic) for memory extraction and synthesis. The daemon starts automatically in the background on first use and stops after 5 minutes of inactivity.
Daemon startup logs: `~/.hermes/logs/hindsight-embed.log`
Daemon runtime logs: `~/.hindsight/profiles/<profile>.log`
## Config
Config file: `~/.hermes/hindsight/config.json`
### Connection
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `cloud` | `cloud` or `local` |
| `api_url` | `https://api.hindsight.vectorize.io` | API URL (cloud mode) |
| `api_url` | `http://localhost:8888` | API URL (local mode, unused — daemon manages its own port) |
### Memory
| Key | Default | Description |
|-----|---------|-------------|
| `bank_id` | `hermes` | Memory bank name |
| `budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` |
### Integration
| Key | Default | Description |
|-----|---------|-------------|
| `memory_mode` | `hybrid` | How memories are integrated into the agent |
| `prefetch_method` | `recall` | Method for automatic context injection |
**memory_mode:**
- `hybrid` — automatic context injection + tools available to the LLM
- `context` — automatic injection only, no tools exposed
- `tools` — tools only, no automatic injection
**prefetch_method:**
- `recall` — injects raw memory facts (fast)
- `reflect` — injects LLM-synthesized summary (slower, more coherent)
### Local Mode LLM
| Key | Default | Description |
|-----|---------|-------------|
| `llm_provider` | `openai` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama` |
| `llm_model` | per-provider | Model name (e.g. `gpt-4o-mini`, `openai/gpt-oss-120b`) |
The LLM API key is stored in `~/.hermes/.env` as `HINDSIGHT_LLM_API_KEY`.
## Tools
Available in `hybrid` and `tools` memory modes:
| Tool | Description |
|------|-------------|
| `hindsight_retain` | Store information with auto entity extraction |
| `hindsight_recall` | Multi-strategy search (semantic + entity graph) |
| `hindsight_reflect` | Cross-memory synthesis (LLM-powered) |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `HINDSIGHT_API_KEY` | API key for Hindsight Cloud |
| `HINDSIGHT_LLM_API_KEY` | LLM API key for local mode |
| `HINDSIGHT_API_URL` | Override API endpoint |
| `HINDSIGHT_BANK_ID` | Override bank name |
| `HINDSIGHT_BUDGET` | Override recall budget |
| `HINDSIGHT_MODE` | Override mode (`cloud` / `local`) |
+515
View File
@@ -0,0 +1,515 @@
"""Hindsight memory plugin — MemoryProvider interface.
Long-term memory with knowledge graph, entity resolution, and multi-strategy
retrieval. Supports cloud (API key) and local modes.
Original PR #1811 by benfrank241, adapted to MemoryProvider ABC.
Config via environment variables:
HINDSIGHT_API_KEY API key for Hindsight Cloud
HINDSIGHT_BANK_ID memory bank identifier (default: hermes)
HINDSIGHT_BUDGET recall budget: low/mid/high (default: mid)
HINDSIGHT_API_URL API endpoint
HINDSIGHT_MODE cloud or local (default: cloud)
Or via $HERMES_HOME/hindsight/config.json (profile-scoped), falling back to
~/.hindsight/config.json (legacy, shared) for backward compatibility.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import threading
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
_DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
_DEFAULT_LOCAL_URL = "http://localhost:8888"
_VALID_BUDGETS = {"low", "mid", "high"}
_PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
"anthropic": "claude-haiku-4-5",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"ollama": "gemma3:12b",
"lmstudio": "local-model",
}
# ---------------------------------------------------------------------------
# Dedicated event loop for Hindsight async calls (one per process, reused).
# Avoids creating ephemeral loops that leak aiohttp sessions.
# ---------------------------------------------------------------------------
_loop: asyncio.AbstractEventLoop | None = None
_loop_thread: threading.Thread | None = None
_loop_lock = threading.Lock()
def _get_loop() -> asyncio.AbstractEventLoop:
"""Return a long-lived event loop running on a background thread."""
global _loop, _loop_thread
with _loop_lock:
if _loop is not None and _loop.is_running():
return _loop
_loop = asyncio.new_event_loop()
def _run():
asyncio.set_event_loop(_loop)
_loop.run_forever()
_loop_thread = threading.Thread(target=_run, daemon=True, name="hindsight-loop")
_loop_thread.start()
return _loop
def _run_sync(coro, timeout: float = 120.0):
"""Schedule *coro* on the shared loop and block until done."""
loop = _get_loop()
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result(timeout=timeout)
# ---------------------------------------------------------------------------
# Tool schemas
# ---------------------------------------------------------------------------
RETAIN_SCHEMA = {
"name": "hindsight_retain",
"description": (
"Store information to long-term memory. Hindsight automatically "
"extracts structured facts, resolves entities, and indexes for retrieval."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The information to store."},
"context": {"type": "string", "description": "Short label (e.g. 'user preference', 'project decision')."},
},
"required": ["content"],
},
}
RECALL_SCHEMA = {
"name": "hindsight_recall",
"description": (
"Search long-term memory. Returns memories ranked by relevance using "
"semantic search, keyword matching, entity graph traversal, and reranking."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for."},
},
"required": ["query"],
},
}
REFLECT_SCHEMA = {
"name": "hindsight_reflect",
"description": (
"Synthesize a reasoned answer from long-term memories. Unlike recall, "
"this reasons across all stored memories to produce a coherent response."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The question to reflect on."},
},
"required": ["query"],
},
}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def _load_config() -> dict:
"""Load config from profile-scoped path, legacy path, or env vars.
Resolution order:
1. $HERMES_HOME/hindsight/config.json (profile-scoped)
2. ~/.hindsight/config.json (legacy, shared)
3. Environment variables
"""
from pathlib import Path
from hermes_constants import get_hermes_home
# Profile-scoped path (preferred)
profile_path = get_hermes_home() / "hindsight" / "config.json"
if profile_path.exists():
try:
return json.loads(profile_path.read_text(encoding="utf-8"))
except Exception:
pass
# Legacy shared path (backward compat)
legacy_path = Path.home() / ".hindsight" / "config.json"
if legacy_path.exists():
try:
return json.loads(legacy_path.read_text(encoding="utf-8"))
except Exception:
pass
return {
"mode": os.environ.get("HINDSIGHT_MODE", "cloud"),
"apiKey": os.environ.get("HINDSIGHT_API_KEY", ""),
"banks": {
"hermes": {
"bankId": os.environ.get("HINDSIGHT_BANK_ID", "hermes"),
"budget": os.environ.get("HINDSIGHT_BUDGET", "mid"),
"enabled": True,
}
},
}
# ---------------------------------------------------------------------------
# MemoryProvider implementation
# ---------------------------------------------------------------------------
class HindsightMemoryProvider(MemoryProvider):
"""Hindsight long-term memory with knowledge graph and multi-strategy retrieval."""
def __init__(self):
self._config = None
self._api_key = None
self._api_url = _DEFAULT_API_URL
self._bank_id = "hermes"
self._budget = "mid"
self._mode = "cloud"
self._memory_mode = "hybrid" # "context", "tools", or "hybrid"
self._prefetch_method = "recall" # "recall" or "reflect"
self._client = None
self._prefetch_result = ""
self._prefetch_lock = threading.Lock()
self._prefetch_thread = None
self._sync_thread = None
@property
def name(self) -> str:
return "hindsight"
def is_available(self) -> bool:
try:
cfg = _load_config()
mode = cfg.get("mode", "cloud")
if mode == "local":
return True
has_key = bool(cfg.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", ""))
has_url = bool(cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", ""))
return has_key or has_url
except Exception:
return False
def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/hindsight/config.json."""
import json
from pathlib import Path
config_dir = Path(hermes_home) / "hindsight"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.json"
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
def get_config_schema(self):
return [
{"key": "mode", "description": "Cloud API or local embedded mode", "default": "cloud", "choices": ["cloud", "local"]},
{"key": "api_url", "description": "Hindsight API URL", "default": _DEFAULT_API_URL, "when": {"mode": "cloud"}},
{"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://ui.hindsight.vectorize.io", "when": {"mode": "cloud"}},
{"key": "llm_provider", "description": "LLM provider for local mode", "default": "openai", "choices": ["openai", "anthropic", "gemini", "groq", "minimax", "ollama"], "when": {"mode": "local"}},
{"key": "llm_api_key", "description": "LLM API key for local Hindsight", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local"}},
{"key": "llm_model", "description": "LLM model for local mode", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local"}},
{"key": "bank_id", "description": "Memory bank name", "default": "hermes"},
{"key": "budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]},
{"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]},
{"key": "prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]},
]
def _get_client(self):
"""Return the cached Hindsight client (created once, reused)."""
if self._client is None:
if self._mode == "local":
from hindsight import HindsightEmbedded
# Disable __del__ on the class to prevent "attached to a
# different loop" errors during GC — we handle cleanup in
# shutdown() instead.
HindsightEmbedded.__del__ = lambda self: None
self._client = HindsightEmbedded(
profile=self._config.get("profile", "hermes"),
llm_provider=self._config.get("llm_provider", ""),
llm_api_key=self._config.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY", ""),
llm_model=self._config.get("llm_model", ""),
)
else:
from hindsight_client import Hindsight
kwargs = {"base_url": self._api_url, "timeout": 30.0}
if self._api_key:
kwargs["api_key"] = self._api_key
self._client = Hindsight(**kwargs)
return self._client
def initialize(self, session_id: str, **kwargs) -> None:
self._config = _load_config()
self._mode = self._config.get("mode", "cloud")
self._api_key = self._config.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "")
default_url = _DEFAULT_LOCAL_URL if self._mode == "local" else _DEFAULT_API_URL
self._api_url = self._config.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url)
banks = self._config.get("banks", {}).get("hermes", {})
self._bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes")
budget = self._config.get("budget") or banks.get("budget", "mid")
self._budget = budget if budget in _VALID_BUDGETS else "mid"
memory_mode = self._config.get("memory_mode", "hybrid")
self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "hybrid"
prefetch_method = self._config.get("prefetch_method", "recall")
self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall"
logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s",
self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method)
# For local mode, start the embedded daemon in the background so it
# doesn't block the chat. Redirect stdout/stderr to a log file to
# prevent rich startup output from spamming the terminal.
if self._mode == "local":
def _start_daemon():
import traceback
from pathlib import Path
log_dir = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / "hindsight-embed.log"
try:
# Redirect the daemon manager's Rich console to our log file
# instead of stderr. This avoids global fd redirects that
# would capture output from other threads.
import hindsight_embed.daemon_embed_manager as dem
from rich.console import Console
dem.console = Console(file=open(log_path, "a"), force_terminal=False)
client = self._get_client()
profile = self._config.get("profile", "hermes")
# Update the profile .env to match our current config so
# the daemon always starts with the right settings.
# If the config changed and the daemon is running, stop it.
from pathlib import Path as _Path
profile_env = _Path.home() / ".hindsight" / "profiles" / f"{profile}.env"
current_key = self._config.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY", "")
current_provider = self._config.get("llm_provider", "")
current_model = self._config.get("llm_model", "")
# Read saved profile config
saved = {}
if profile_env.exists():
for line in profile_env.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
saved[k.strip()] = v.strip()
config_changed = (
saved.get("HINDSIGHT_API_LLM_PROVIDER") != current_provider or
saved.get("HINDSIGHT_API_LLM_MODEL") != current_model or
saved.get("HINDSIGHT_API_LLM_API_KEY") != current_key
)
if config_changed:
# Write updated profile .env
profile_env.parent.mkdir(parents=True, exist_ok=True)
profile_env.write_text(
f"HINDSIGHT_API_LLM_PROVIDER={current_provider}\n"
f"HINDSIGHT_API_LLM_API_KEY={current_key}\n"
f"HINDSIGHT_API_LLM_MODEL={current_model}\n"
f"HINDSIGHT_API_LOG_LEVEL=info\n"
)
if client._manager.is_running(profile):
with open(log_path, "a") as f:
f.write("\n=== Config changed, restarting daemon ===\n")
client._manager.stop(profile)
client._ensure_started()
with open(log_path, "a") as f:
f.write("\n=== Daemon started successfully ===\n")
except Exception as e:
with open(log_path, "a") as f:
f.write(f"\n=== Daemon startup failed: {e} ===\n")
traceback.print_exc(file=f)
t = threading.Thread(target=_start_daemon, daemon=True, name="hindsight-daemon-start")
t.start()
def system_prompt_block(self) -> str:
if self._memory_mode == "context":
return (
f"# Hindsight Memory\n"
f"Active (context mode). Bank: {self._bank_id}, budget: {self._budget}.\n"
f"Relevant memories are automatically injected into context."
)
if self._memory_mode == "tools":
return (
f"# Hindsight Memory\n"
f"Active (tools mode). Bank: {self._bank_id}, budget: {self._budget}.\n"
f"Use hindsight_recall to search, hindsight_reflect for synthesis, "
f"hindsight_retain to store facts."
)
return (
f"# Hindsight Memory\n"
f"Active. Bank: {self._bank_id}, budget: {self._budget}.\n"
f"Relevant memories are automatically injected into context. "
f"Use hindsight_recall to search, hindsight_reflect for synthesis, "
f"hindsight_retain to store facts."
)
def prefetch(self, query: str, *, session_id: str = "") -> str:
if self._prefetch_thread and self._prefetch_thread.is_alive():
self._prefetch_thread.join(timeout=3.0)
with self._prefetch_lock:
result = self._prefetch_result
self._prefetch_result = ""
if not result:
return ""
return f"## Hindsight Memory\n{result}"
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
if self._memory_mode == "tools":
return
def _run():
try:
client = self._get_client()
if self._prefetch_method == "reflect":
resp = _run_sync(client.areflect(bank_id=self._bank_id, query=query, budget=self._budget))
text = resp.text or ""
else:
resp = _run_sync(client.arecall(bank_id=self._bank_id, query=query, budget=self._budget))
text = "\n".join(r.text for r in resp.results if r.text) if resp.results else ""
if text:
with self._prefetch_lock:
self._prefetch_result = text
except Exception as e:
logger.debug("Hindsight prefetch failed: %s", e)
self._prefetch_thread = threading.Thread(target=_run, daemon=True, name="hindsight-prefetch")
self._prefetch_thread.start()
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Retain conversation turn in background (non-blocking)."""
combined = f"User: {user_content}\nAssistant: {assistant_content}"
def _sync():
try:
client = self._get_client()
_run_sync(client.aretain(
bank_id=self._bank_id, content=combined, context="conversation"
))
except Exception as e:
logger.warning("Hindsight sync failed: %s", e)
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(target=_sync, daemon=True, name="hindsight-sync")
self._sync_thread.start()
def get_tool_schemas(self) -> List[Dict[str, Any]]:
if self._memory_mode == "context":
return []
return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA]
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
try:
client = self._get_client()
except Exception as e:
logger.warning("Hindsight client init failed: %s", e)
return json.dumps({"error": f"Hindsight client unavailable: {e}"})
if tool_name == "hindsight_retain":
content = args.get("content", "")
if not content:
return json.dumps({"error": "Missing required parameter: content"})
context = args.get("context")
try:
_run_sync(client.aretain(
bank_id=self._bank_id, content=content, context=context
))
return json.dumps({"result": "Memory stored successfully."})
except Exception as e:
logger.warning("hindsight_retain failed: %s", e)
return json.dumps({"error": f"Failed to store memory: {e}"})
elif tool_name == "hindsight_recall":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
try:
resp = _run_sync(client.arecall(
bank_id=self._bank_id, query=query, budget=self._budget
))
if not resp.results:
return json.dumps({"result": "No relevant memories found."})
lines = [f"{i}. {r.text}" for i, r in enumerate(resp.results, 1)]
return json.dumps({"result": "\n".join(lines)})
except Exception as e:
logger.warning("hindsight_recall failed: %s", e)
return json.dumps({"error": f"Failed to search memory: {e}"})
elif tool_name == "hindsight_reflect":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
try:
resp = _run_sync(client.areflect(
bank_id=self._bank_id, query=query, budget=self._budget
))
return json.dumps({"result": resp.text or "No relevant memories found."})
except Exception as e:
logger.warning("hindsight_reflect failed: %s", e)
return json.dumps({"error": f"Failed to reflect: {e}"})
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def shutdown(self) -> None:
global _loop, _loop_thread
for t in (self._prefetch_thread, self._sync_thread):
if t and t.is_alive():
t.join(timeout=5.0)
if self._client is not None:
try:
if self._mode == "local":
# Use the public close() API. The RuntimeError from
# aiohttp's "attached to a different loop" is expected
# and harmless — the daemon keeps running independently.
try:
self._client.close()
except RuntimeError:
pass
else:
_run_sync(self._client.aclose())
except Exception:
pass
self._client = None
# Stop the background event loop so no tasks are pending at exit
if _loop is not None and _loop.is_running():
_loop.call_soon_threadsafe(_loop.stop)
if _loop_thread is not None:
_loop_thread.join(timeout=5.0)
_loop = None
_loop_thread = None
def register(ctx) -> None:
"""Register Hindsight as a memory provider plugin."""
ctx.register_memory_provider(HindsightMemoryProvider())
+10
View File
@@ -0,0 +1,10 @@
name: hindsight
version: 1.0.0
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
pip_dependencies:
- hindsight-client
- hindsight-all
requires_env:
- HINDSIGHT_API_KEY
hooks:
- on_session_end
+36
View File
@@ -0,0 +1,36 @@
# Holographic Memory Provider
Local SQLite fact store with FTS5 search, trust scoring, entity resolution, and HRR-based compositional retrieval.
## Requirements
None — uses SQLite (always available). NumPy optional for HRR algebra.
## Setup
```bash
hermes memory setup # select "holographic"
```
Or manually:
```bash
hermes config set memory.provider holographic
```
## Config
Config in `config.yaml` under `plugins.hermes-memory-store`:
| Key | Default | Description |
|-----|---------|-------------|
| `db_path` | `$HERMES_HOME/memory_store.db` | SQLite database path |
| `auto_extract` | `false` | Auto-extract facts at session end |
| `default_trust` | `0.5` | Default trust score for new facts |
| `hrr_dim` | `1024` | HRR vector dimensions |
## Tools
| Tool | Description |
|------|-------------|
| `fact_store` | 9 actions: add, search, probe, related, reason, contradict, update, remove, list |
| `fact_feedback` | Rate facts as helpful/unhelpful (trains trust scores) |
+407
View File
@@ -0,0 +1,407 @@
"""hermes-memory-store — holographic memory plugin using MemoryProvider interface.
Registers as a MemoryProvider plugin, giving the agent structured fact storage
with entity resolution, trust scoring, and HRR-based compositional retrieval.
Original plugin by dusterbloom (PR #2351), adapted to the MemoryProvider ABC.
Config in $HERMES_HOME/config.yaml (profile-scoped):
plugins:
hermes-memory-store:
db_path: $HERMES_HOME/memory_store.db # omit to use the default
auto_extract: false
default_trust: 0.5
min_trust_threshold: 0.3
temporal_decay_half_life: 0
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List
from agent.memory_provider import MemoryProvider
from .store import MemoryStore
from .retrieval import FactRetriever
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool schemas (unchanged from original PR)
# ---------------------------------------------------------------------------
FACT_STORE_SCHEMA = {
"name": "fact_store",
"description": (
"Deep structured memory with algebraic reasoning. "
"Use alongside the memory tool — memory for always-on context, "
"fact_store for deep recall and compositional queries.\n\n"
"ACTIONS (simple → powerful):\n"
"• add — Store a fact the user would expect you to remember.\n"
"• search — Keyword lookup ('editor config', 'deploy process').\n"
"• probe — Entity recall: ALL facts about a person/thing.\n"
"• related — What connects to an entity? Structural adjacency.\n"
"• reason — Compositional: facts connected to MULTIPLE entities simultaneously.\n"
"• contradict — Memory hygiene: find facts making conflicting claims.\n"
"• update/remove/list — CRUD operations.\n\n"
"IMPORTANT: Before answering questions about the user, ALWAYS probe or reason first."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "search", "probe", "related", "reason", "contradict", "update", "remove", "list"],
},
"content": {"type": "string", "description": "Fact content (required for 'add')."},
"query": {"type": "string", "description": "Search query (required for 'search')."},
"entity": {"type": "string", "description": "Entity name for 'probe'/'related'."},
"entities": {"type": "array", "items": {"type": "string"}, "description": "Entity names for 'reason'."},
"fact_id": {"type": "integer", "description": "Fact ID for 'update'/'remove'."},
"category": {"type": "string", "enum": ["user_pref", "project", "tool", "general"]},
"tags": {"type": "string", "description": "Comma-separated tags."},
"trust_delta": {"type": "number", "description": "Trust adjustment for 'update'."},
"min_trust": {"type": "number", "description": "Minimum trust filter (default: 0.3)."},
"limit": {"type": "integer", "description": "Max results (default: 10)."},
},
"required": ["action"],
},
}
FACT_FEEDBACK_SCHEMA = {
"name": "fact_feedback",
"description": (
"Rate a fact after using it. Mark 'helpful' if accurate, 'unhelpful' if outdated. "
"This trains the memory — good facts rise, bad facts sink."
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["helpful", "unhelpful"]},
"fact_id": {"type": "integer", "description": "The fact ID to rate."},
},
"required": ["action", "fact_id"],
},
}
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def _load_plugin_config() -> dict:
from hermes_constants import get_hermes_home
config_path = get_hermes_home() / "config.yaml"
if not config_path.exists():
return {}
try:
import yaml
with open(config_path) as f:
all_config = yaml.safe_load(f) or {}
return all_config.get("plugins", {}).get("hermes-memory-store", {}) or {}
except Exception:
return {}
# ---------------------------------------------------------------------------
# MemoryProvider implementation
# ---------------------------------------------------------------------------
class HolographicMemoryProvider(MemoryProvider):
"""Holographic memory with structured facts, entity resolution, and HRR retrieval."""
def __init__(self, config: dict | None = None):
self._config = config or _load_plugin_config()
self._store = None
self._retriever = None
self._min_trust = float(self._config.get("min_trust_threshold", 0.3))
@property
def name(self) -> str:
return "holographic"
def is_available(self) -> bool:
return True # SQLite is always available, numpy is optional
def save_config(self, values, hermes_home):
"""Write config to config.yaml under plugins.hermes-memory-store."""
from pathlib import Path
config_path = Path(hermes_home) / "config.yaml"
try:
import yaml
existing = {}
if config_path.exists():
with open(config_path) as f:
existing = yaml.safe_load(f) or {}
existing.setdefault("plugins", {})
existing["plugins"]["hermes-memory-store"] = values
with open(config_path, "w") as f:
yaml.dump(existing, f, default_flow_style=False)
except Exception:
pass
def get_config_schema(self):
from hermes_constants import display_hermes_home
_default_db = f"{display_hermes_home()}/memory_store.db"
return [
{"key": "db_path", "description": "SQLite database path", "default": _default_db},
{"key": "auto_extract", "description": "Auto-extract facts at session end", "default": "false", "choices": ["true", "false"]},
{"key": "default_trust", "description": "Default trust score for new facts", "default": "0.5"},
{"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"},
]
def initialize(self, session_id: str, **kwargs) -> None:
from hermes_constants import get_hermes_home
_hermes_home = str(get_hermes_home())
_default_db = _hermes_home + "/memory_store.db"
db_path = self._config.get("db_path", _default_db)
# Expand $HERMES_HOME in user-supplied paths so config values like
# "$HERMES_HOME/memory_store.db" or "~/.hermes/memory_store.db" both
# resolve to the active profile's directory.
if isinstance(db_path, str):
db_path = db_path.replace("$HERMES_HOME", _hermes_home)
db_path = db_path.replace("${HERMES_HOME}", _hermes_home)
default_trust = float(self._config.get("default_trust", 0.5))
hrr_dim = int(self._config.get("hrr_dim", 1024))
hrr_weight = float(self._config.get("hrr_weight", 0.3))
temporal_decay = int(self._config.get("temporal_decay_half_life", 0))
self._store = MemoryStore(db_path=db_path, default_trust=default_trust, hrr_dim=hrr_dim)
self._retriever = FactRetriever(
store=self._store,
temporal_decay_half_life=temporal_decay,
hrr_weight=hrr_weight,
hrr_dim=hrr_dim,
)
self._session_id = session_id
def system_prompt_block(self) -> str:
if not self._store:
return ""
try:
total = self._store._conn.execute(
"SELECT COUNT(*) FROM facts"
).fetchone()[0]
except Exception:
total = 0
if total == 0:
return (
"# Holographic Memory\n"
"Active. Empty fact store — proactively add facts the user would expect you to remember.\n"
"Use fact_store(action='add') to store durable structured facts about people, projects, preferences, decisions.\n"
"Use fact_feedback to rate facts after using them (trains trust scores)."
)
return (
f"# Holographic Memory\n"
f"Active. {total} facts stored with entity resolution and trust scoring.\n"
f"Use fact_store to search, probe entities, reason across entities, or add facts.\n"
f"Use fact_feedback to rate facts after using them (trains trust scores)."
)
def prefetch(self, query: str, *, session_id: str = "") -> str:
if not self._retriever or not query:
return ""
try:
results = self._retriever.search(query, min_trust=self._min_trust, limit=5)
if not results:
return ""
lines = []
for r in results:
trust = r.get("trust_score", r.get("trust", 0))
lines.append(f"- [{trust:.1f}] {r.get('content', '')}")
return "## Holographic Memory\n" + "\n".join(lines)
except Exception as e:
logger.debug("Holographic prefetch failed: %s", e)
return ""
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
# Holographic memory stores explicit facts via tools, not auto-sync.
# The on_session_end hook handles auto-extraction if configured.
pass
def get_tool_schemas(self) -> List[Dict[str, Any]]:
return [FACT_STORE_SCHEMA, FACT_FEEDBACK_SCHEMA]
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
if tool_name == "fact_store":
return self._handle_fact_store(args)
elif tool_name == "fact_feedback":
return self._handle_fact_feedback(args)
return json.dumps({"error": f"Unknown tool: {tool_name}"})
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
if not self._config.get("auto_extract", False):
return
if not self._store or not messages:
return
self._auto_extract_facts(messages)
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Mirror built-in memory writes as facts."""
if action == "add" and self._store and content:
try:
category = "user_pref" if target == "user" else "general"
self._store.add_fact(content, category=category)
except Exception as e:
logger.debug("Holographic memory_write mirror failed: %s", e)
def shutdown(self) -> None:
self._store = None
self._retriever = None
# -- Tool handlers -------------------------------------------------------
def _handle_fact_store(self, args: dict) -> str:
try:
action = args["action"]
store = self._store
retriever = self._retriever
if action == "add":
fact_id = store.add_fact(
args["content"],
category=args.get("category", "general"),
tags=args.get("tags", ""),
)
return json.dumps({"fact_id": fact_id, "status": "added"})
elif action == "search":
results = retriever.search(
args["query"],
category=args.get("category"),
min_trust=float(args.get("min_trust", self._min_trust)),
limit=int(args.get("limit", 10)),
)
return json.dumps({"results": results, "count": len(results)})
elif action == "probe":
results = retriever.probe(
args["entity"],
category=args.get("category"),
limit=int(args.get("limit", 10)),
)
return json.dumps({"results": results, "count": len(results)})
elif action == "related":
results = retriever.related(
args["entity"],
category=args.get("category"),
limit=int(args.get("limit", 10)),
)
return json.dumps({"results": results, "count": len(results)})
elif action == "reason":
entities = args.get("entities", [])
if not entities:
return json.dumps({"error": "reason requires 'entities' list"})
results = retriever.reason(
entities,
category=args.get("category"),
limit=int(args.get("limit", 10)),
)
return json.dumps({"results": results, "count": len(results)})
elif action == "contradict":
results = retriever.contradict(
category=args.get("category"),
limit=int(args.get("limit", 10)),
)
return json.dumps({"results": results, "count": len(results)})
elif action == "update":
updated = store.update_fact(
int(args["fact_id"]),
content=args.get("content"),
trust_delta=float(args["trust_delta"]) if "trust_delta" in args else None,
tags=args.get("tags"),
category=args.get("category"),
)
return json.dumps({"updated": updated})
elif action == "remove":
removed = store.remove_fact(int(args["fact_id"]))
return json.dumps({"removed": removed})
elif action == "list":
facts = store.list_facts(
category=args.get("category"),
min_trust=float(args.get("min_trust", 0.0)),
limit=int(args.get("limit", 10)),
)
return json.dumps({"facts": facts, "count": len(facts)})
else:
return json.dumps({"error": f"Unknown action: {action}"})
except KeyError as exc:
return json.dumps({"error": f"Missing required argument: {exc}"})
except Exception as exc:
return json.dumps({"error": str(exc)})
def _handle_fact_feedback(self, args: dict) -> str:
try:
fact_id = int(args["fact_id"])
helpful = args["action"] == "helpful"
result = self._store.record_feedback(fact_id, helpful=helpful)
return json.dumps(result)
except KeyError as exc:
return json.dumps({"error": f"Missing required argument: {exc}"})
except Exception as exc:
return json.dumps({"error": str(exc)})
# -- Auto-extraction (on_session_end) ------------------------------------
def _auto_extract_facts(self, messages: list) -> None:
_PREF_PATTERNS = [
re.compile(r'\bI\s+(?:prefer|like|love|use|want|need)\s+(.+)', re.IGNORECASE),
re.compile(r'\bmy\s+(?:favorite|preferred|default)\s+\w+\s+is\s+(.+)', re.IGNORECASE),
re.compile(r'\bI\s+(?:always|never|usually)\s+(.+)', re.IGNORECASE),
]
_DECISION_PATTERNS = [
re.compile(r'\bwe\s+(?:decided|agreed|chose)\s+(?:to\s+)?(.+)', re.IGNORECASE),
re.compile(r'\bthe\s+project\s+(?:uses|needs|requires)\s+(.+)', re.IGNORECASE),
]
extracted = 0
for msg in messages:
if msg.get("role") != "user":
continue
content = msg.get("content", "")
if not isinstance(content, str) or len(content) < 10:
continue
for pattern in _PREF_PATTERNS:
if pattern.search(content):
try:
self._store.add_fact(content[:400], category="user_pref")
extracted += 1
except Exception:
pass
break
for pattern in _DECISION_PATTERNS:
if pattern.search(content):
try:
self._store.add_fact(content[:400], category="project")
extracted += 1
except Exception:
pass
break
if extracted:
logger.info("Auto-extracted %d facts from conversation", extracted)
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
def register(ctx) -> None:
"""Register the holographic memory provider with the plugin system."""
config = _load_plugin_config()
provider = HolographicMemoryProvider(config=config)
ctx.register_memory_provider(provider)
+203
View File
@@ -0,0 +1,203 @@
"""Holographic Reduced Representations (HRR) with phase encoding.
HRRs are a vector symbolic architecture for encoding compositional structure
into fixed-width distributed representations. This module uses *phase vectors*:
each concept is a vector of angles in [0, 2π). The algebraic operations are:
bind circular convolution (phase addition) associates two concepts
unbind circular correlation (phase subtraction) retrieves a bound value
bundle superposition (circular mean) merges multiple concepts
Phase encoding is numerically stable, avoids the magnitude collapse of
traditional complex-number HRRs, and maps cleanly to cosine similarity.
Atoms are generated deterministically from SHA-256 so representations are
identical across processes, machines, and language versions.
References:
Plate (1995) Holographic Reduced Representations
Gayler (2004) Vector Symbolic Architectures answer Jackendoff's challenges
"""
import hashlib
import logging
import struct
import math
try:
import numpy as np
_HAS_NUMPY = True
except ImportError:
_HAS_NUMPY = False
logger = logging.getLogger(__name__)
_TWO_PI = 2.0 * math.pi
def _require_numpy() -> None:
if not _HAS_NUMPY:
raise RuntimeError("numpy is required for holographic operations")
def encode_atom(word: str, dim: int = 1024) -> "np.ndarray":
"""Deterministic phase vector via SHA-256 counter blocks.
Uses hashlib (not numpy RNG) for cross-platform reproducibility.
Algorithm:
- Generate enough SHA-256 blocks by hashing f"{word}:{i}" for i=0,1,2,...
- Concatenate digests, interpret as uint16 values via struct.unpack
- Scale to [0, 2π): phases = values * (2π / 65536)
- Truncate to dim elements
- Returns np.float64 array of shape (dim,)
"""
_require_numpy()
# Each SHA-256 digest is 32 bytes = 16 uint16 values.
values_per_block = 16
blocks_needed = math.ceil(dim / values_per_block)
uint16_values: list[int] = []
for i in range(blocks_needed):
digest = hashlib.sha256(f"{word}:{i}".encode()).digest()
uint16_values.extend(struct.unpack("<16H", digest))
phases = np.array(uint16_values[:dim], dtype=np.float64) * (_TWO_PI / 65536.0)
return phases
def bind(a: "np.ndarray", b: "np.ndarray") -> "np.ndarray":
"""Circular convolution = element-wise phase addition.
Binding associates two concepts into a single composite vector.
The result is dissimilar to both inputs (quasi-orthogonal).
"""
_require_numpy()
return (a + b) % _TWO_PI
def unbind(memory: "np.ndarray", key: "np.ndarray") -> "np.ndarray":
"""Circular correlation = element-wise phase subtraction.
Unbinding retrieves the value associated with a key from a memory vector.
unbind(bind(a, b), a) b (up to superposition noise)
"""
_require_numpy()
return (memory - key) % _TWO_PI
def bundle(*vectors: "np.ndarray") -> "np.ndarray":
"""Superposition via circular mean of complex exponentials.
Bundling merges multiple vectors into one that is similar to each input.
The result can hold O(sqrt(dim)) items before similarity degrades.
"""
_require_numpy()
complex_sum = np.sum([np.exp(1j * v) for v in vectors], axis=0)
return np.angle(complex_sum) % _TWO_PI
def similarity(a: "np.ndarray", b: "np.ndarray") -> float:
"""Phase cosine similarity. Range [-1, 1].
Returns 1.0 for identical vectors, near 0.0 for random (unrelated) vectors,
and -1.0 for perfectly anti-correlated vectors.
"""
_require_numpy()
return float(np.mean(np.cos(a - b)))
def encode_text(text: str, dim: int = 1024) -> "np.ndarray":
"""Bag-of-words: bundle of atom vectors for each token.
Tokenizes by lowercasing, splitting on whitespace, and stripping
leading/trailing punctuation from each token.
Returns bundle of all token atom vectors.
If text is empty or produces no tokens, returns encode_atom("__hrr_empty__", dim).
"""
_require_numpy()
tokens = [
token.strip(".,!?;:\"'()[]{}")
for token in text.lower().split()
]
tokens = [t for t in tokens if t]
if not tokens:
return encode_atom("__hrr_empty__", dim)
atom_vectors = [encode_atom(token, dim) for token in tokens]
return bundle(*atom_vectors)
def encode_fact(content: str, entities: list[str], dim: int = 1024) -> "np.ndarray":
"""Structured encoding: content bound to ROLE_CONTENT, each entity bound to ROLE_ENTITY, all bundled.
Role vectors are reserved atoms: "__hrr_role_content__", "__hrr_role_entity__"
Components:
1. bind(encode_text(content, dim), encode_atom("__hrr_role_content__", dim))
2. For each entity: bind(encode_atom(entity.lower(), dim), encode_atom("__hrr_role_entity__", dim))
3. bundle all components together
This enables algebraic extraction:
unbind(fact, bind(entity, ROLE_ENTITY)) content_vector
"""
_require_numpy()
role_content = encode_atom("__hrr_role_content__", dim)
role_entity = encode_atom("__hrr_role_entity__", dim)
components: list[np.ndarray] = [
bind(encode_text(content, dim), role_content)
]
for entity in entities:
components.append(bind(encode_atom(entity.lower(), dim), role_entity))
return bundle(*components)
def phases_to_bytes(phases: "np.ndarray") -> bytes:
"""Serialize phase vector to bytes. float64 tobytes — 8 KB at dim=1024."""
_require_numpy()
return phases.tobytes()
def bytes_to_phases(data: bytes) -> "np.ndarray":
"""Deserialize bytes back to phase vector. Inverse of phases_to_bytes.
The .copy() call is required because frombuffer returns a read-only view
backed by the bytes object; callers expect a mutable array.
"""
_require_numpy()
return np.frombuffer(data, dtype=np.float64).copy()
def snr_estimate(dim: int, n_items: int) -> float:
"""Signal-to-noise ratio estimate for holographic storage.
SNR = sqrt(dim / n_items) when n_items > 0, else inf.
The SNR falls below 2.0 when n_items > dim / 4, meaning retrieval
errors become likely. Logs a warning when this threshold is crossed.
"""
_require_numpy()
if n_items <= 0:
return float("inf")
snr = math.sqrt(dim / n_items)
if snr < 2.0:
logger.warning(
"HRR storage near capacity: SNR=%.2f (dim=%d, n_items=%d). "
"Retrieval accuracy may degrade. Consider increasing dim or reducing stored items.",
snr,
dim,
n_items,
)
return snr
+5
View File
@@ -0,0 +1,5 @@
name: holographic
version: 0.1.0
description: "Holographic memory — local SQLite fact store with FTS5 search, trust scoring, and HRR-based compositional retrieval."
hooks:
- on_session_end
+593
View File
@@ -0,0 +1,593 @@
"""Hybrid keyword/BM25 retrieval for the memory store.
Ported from KIK memory_agent.py combines FTS5 full-text search with
Jaccard similarity reranking and trust-weighted scoring.
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .store import MemoryStore
try:
from . import holographic as hrr
except ImportError:
import holographic as hrr # type: ignore[no-redef]
class FactRetriever:
"""Multi-strategy fact retrieval with trust-weighted scoring."""
def __init__(
self,
store: MemoryStore,
temporal_decay_half_life: int = 0, # days, 0 = disabled
fts_weight: float = 0.4,
jaccard_weight: float = 0.3,
hrr_weight: float = 0.3,
hrr_dim: int = 1024,
):
self.store = store
self.half_life = temporal_decay_half_life
self.hrr_dim = hrr_dim
# Auto-redistribute weights if numpy unavailable
if hrr_weight > 0 and not hrr._HAS_NUMPY:
fts_weight = 0.6
jaccard_weight = 0.4
hrr_weight = 0.0
self.fts_weight = fts_weight
self.jaccard_weight = jaccard_weight
self.hrr_weight = hrr_weight
def search(
self,
query: str,
category: str | None = None,
min_trust: float = 0.3,
limit: int = 10,
) -> list[dict]:
"""Hybrid search: FTS5 candidates → Jaccard rerank → trust weighting.
Pipeline:
1. FTS5 search: Get limit*3 candidates from SQLite full-text search
2. Jaccard boost: Token overlap between query and fact content
3. Trust weighting: final_score = relevance * trust_score
4. Temporal decay (optional): decay = 0.5^(age_days / half_life)
Returns list of dicts with fact data + 'score' field, sorted by score desc.
"""
# Stage 1: Get FTS5 candidates (more than limit for reranking headroom)
candidates = self._fts_candidates(query, category, min_trust, limit * 3)
if not candidates:
return []
# Stage 2: Rerank with Jaccard + trust + optional decay
query_tokens = self._tokenize(query)
scored = []
for fact in candidates:
content_tokens = self._tokenize(fact["content"])
tag_tokens = self._tokenize(fact.get("tags", ""))
all_tokens = content_tokens | tag_tokens
jaccard = self._jaccard_similarity(query_tokens, all_tokens)
fts_score = fact.get("fts_rank", 0.0)
# HRR similarity
if self.hrr_weight > 0 and fact.get("hrr_vector"):
fact_vec = hrr.bytes_to_phases(fact["hrr_vector"])
query_vec = hrr.encode_text(query, self.hrr_dim)
hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0 # shift to [0,1]
else:
hrr_sim = 0.5 # neutral
# Combine FTS5 + Jaccard + HRR
relevance = (self.fts_weight * fts_score
+ self.jaccard_weight * jaccard
+ self.hrr_weight * hrr_sim)
# Trust weighting
score = relevance * fact["trust_score"]
# Optional temporal decay
if self.half_life > 0:
score *= self._temporal_decay(fact.get("updated_at") or fact.get("created_at"))
fact["score"] = score
scored.append(fact)
# Sort by score descending, return top limit
scored.sort(key=lambda x: x["score"], reverse=True)
results = scored[:limit]
# Strip raw HRR bytes — callers expect JSON-serializable dicts
for fact in results:
fact.pop("hrr_vector", None)
return results
def probe(
self,
entity: str,
category: str | None = None,
limit: int = 10,
) -> list[dict]:
"""Compositional entity query using HRR algebra.
Unbinds entity from memory bank to extract associated content.
This is NOT keyword search it uses algebraic structure to find facts
where the entity plays a structural role.
Falls back to FTS5 search if numpy unavailable.
"""
if not hrr._HAS_NUMPY:
# Fallback to keyword search on entity name
return self.search(entity, category=category, limit=limit)
conn = self.store._conn
# Encode entity as role-bound vector
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
entity_vec = hrr.encode_atom(entity.lower(), self.hrr_dim)
probe_key = hrr.bind(entity_vec, role_entity)
# Try category-specific bank first, then all facts
if category:
bank_name = f"cat:{category}"
bank_row = conn.execute(
"SELECT vector FROM memory_banks WHERE bank_name = ?",
(bank_name,),
).fetchone()
if bank_row:
bank_vec = hrr.bytes_to_phases(bank_row["vector"])
extracted = hrr.unbind(bank_vec, probe_key)
# Use extracted signal to score individual facts
return self._score_facts_by_vector(
extracted, category=category, limit=limit
)
# Score against individual fact vectors directly
where = "WHERE hrr_vector IS NOT NULL"
params: list = []
if category:
where += " AND category = ?"
params.append(category)
rows = conn.execute(
f"""
SELECT fact_id, content, category, tags, trust_score,
retrieval_count, helpful_count, created_at, updated_at,
hrr_vector
FROM facts
{where}
""",
params,
).fetchall()
if not rows:
# Final fallback: keyword search
return self.search(entity, category=category, limit=limit)
scored = []
for row in rows:
fact = dict(row)
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"))
# Unbind probe key from fact to see if entity is structurally present
residual = hrr.unbind(fact_vec, probe_key)
# Compare residual against content signal
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
content_vec = hrr.bind(hrr.encode_text(fact["content"], self.hrr_dim), role_content)
sim = hrr.similarity(residual, content_vec)
fact["score"] = (sim + 1.0) / 2.0 * fact["trust_score"]
scored.append(fact)
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:limit]
def related(
self,
entity: str,
category: str | None = None,
limit: int = 10,
) -> list[dict]:
"""Discover facts that share structural connections with an entity.
Unlike probe (which finds facts *about* an entity), related finds
facts that are connected through shared context e.g., other entities
mentioned alongside this one, or content that overlaps structurally.
Falls back to FTS5 search if numpy unavailable.
"""
if not hrr._HAS_NUMPY:
return self.search(entity, category=category, limit=limit)
conn = self.store._conn
# Encode entity as a bare atom (not role-bound — we want ANY structural match)
entity_vec = hrr.encode_atom(entity.lower(), self.hrr_dim)
# Get all facts with vectors
where = "WHERE hrr_vector IS NOT NULL"
params: list = []
if category:
where += " AND category = ?"
params.append(category)
rows = conn.execute(
f"""
SELECT fact_id, content, category, tags, trust_score,
retrieval_count, helpful_count, created_at, updated_at,
hrr_vector
FROM facts
{where}
""",
params,
).fetchall()
if not rows:
return self.search(entity, category=category, limit=limit)
# Score each fact by how much the entity's atom appears in its vector
# This catches both role-bound entity matches AND content word matches
scored = []
for row in rows:
fact = dict(row)
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"))
# Check structural similarity: unbind entity from fact
residual = hrr.unbind(fact_vec, entity_vec)
# A high-similarity residual to ANY known role vector means this entity
# plays a structural role in the fact
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
entity_role_sim = hrr.similarity(residual, role_entity)
content_role_sim = hrr.similarity(residual, role_content)
# Take the max — entity could appear in either role
best_sim = max(entity_role_sim, content_role_sim)
fact["score"] = (best_sim + 1.0) / 2.0 * fact["trust_score"]
scored.append(fact)
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:limit]
def reason(
self,
entities: list[str],
category: str | None = None,
limit: int = 10,
) -> list[dict]:
"""Multi-entity compositional query — vector-space JOIN.
Given multiple entities, algebraically intersects their structural
connections to find facts related to ALL of them simultaneously.
This is compositional reasoning that no embedding DB can do.
Example: reason(["peppi", "backend"]) finds facts where peppi AND
backend both play structural roles without keyword matching.
Falls back to FTS5 search if numpy unavailable.
"""
if not hrr._HAS_NUMPY or not entities:
# Fallback: search with all entities as keywords
query = " ".join(entities)
return self.search(query, category=category, limit=limit)
conn = self.store._conn
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
# For each entity, compute what the bank "remembers" about it
# by unbinding entity+role from each fact vector
entity_residuals = []
for entity in entities:
entity_vec = hrr.encode_atom(entity.lower(), self.hrr_dim)
probe_key = hrr.bind(entity_vec, role_entity)
entity_residuals.append(probe_key)
# Get all facts with vectors
where = "WHERE hrr_vector IS NOT NULL"
params: list = []
if category:
where += " AND category = ?"
params.append(category)
rows = conn.execute(
f"""
SELECT fact_id, content, category, tags, trust_score,
retrieval_count, helpful_count, created_at, updated_at,
hrr_vector
FROM facts
{where}
""",
params,
).fetchall()
if not rows:
query = " ".join(entities)
return self.search(query, category=category, limit=limit)
# Score each fact by how much EACH entity is structurally present.
# A fact scores high only if ALL entities have structural presence
# (AND semantics via min, vs OR which would use mean/max).
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
scored = []
for row in rows:
fact = dict(row)
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"))
entity_scores = []
for probe_key in entity_residuals:
residual = hrr.unbind(fact_vec, probe_key)
sim = hrr.similarity(residual, role_content)
entity_scores.append(sim)
min_sim = min(entity_scores)
fact["score"] = (min_sim + 1.0) / 2.0 * fact["trust_score"]
scored.append(fact)
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:limit]
def contradict(
self,
category: str | None = None,
threshold: float = 0.3,
limit: int = 10,
) -> list[dict]:
"""Find potentially contradictory facts via entity overlap + content divergence.
Two facts contradict when they share entities (same subject) but have
low content-vector similarity (different claims). This is automated
memory hygiene no other memory system does this.
Returns pairs of facts with a contradiction score.
Falls back to empty list if numpy unavailable.
"""
if not hrr._HAS_NUMPY:
return []
conn = self.store._conn
# Get all facts with vectors and their linked entities
where = "WHERE f.hrr_vector IS NOT NULL"
params: list = []
if category:
where += " AND f.category = ?"
params.append(category)
rows = conn.execute(
f"""
SELECT f.fact_id, f.content, f.category, f.tags, f.trust_score,
f.created_at, f.updated_at, f.hrr_vector
FROM facts f
{where}
""",
params,
).fetchall()
if len(rows) < 2:
return []
# Guard against O(n²) explosion on large fact stores.
# At 500 facts, that's ~125K comparisons — acceptable.
# Above that, only check the most recently updated facts.
_MAX_CONTRADICT_FACTS = 500
if len(rows) > _MAX_CONTRADICT_FACTS:
rows = sorted(rows, key=lambda r: r["updated_at"] or r["created_at"], reverse=True)
rows = rows[:_MAX_CONTRADICT_FACTS]
# Build entity sets per fact
fact_entities: dict[int, set[str]] = {}
for row in rows:
fid = row["fact_id"]
entity_rows = conn.execute(
"""
SELECT e.name FROM entities e
JOIN fact_entities fe ON fe.entity_id = e.entity_id
WHERE fe.fact_id = ?
""",
(fid,),
).fetchall()
fact_entities[fid] = {r["name"].lower() for r in entity_rows}
# Compare all pairs: high entity overlap + low content similarity = contradiction
facts = [dict(r) for r in rows]
contradictions = []
for i in range(len(facts)):
for j in range(i + 1, len(facts)):
f1, f2 = facts[i], facts[j]
ents1 = fact_entities.get(f1["fact_id"], set())
ents2 = fact_entities.get(f2["fact_id"], set())
if not ents1 or not ents2:
continue
# Entity overlap (Jaccard)
entity_overlap = len(ents1 & ents2) / len(ents1 | ents2) if (ents1 | ents2) else 0.0
if entity_overlap < 0.3:
continue # Not enough entity overlap to be contradictory
# Content similarity via HRR vectors
v1 = hrr.bytes_to_phases(f1["hrr_vector"])
v2 = hrr.bytes_to_phases(f2["hrr_vector"])
content_sim = hrr.similarity(v1, v2)
# High entity overlap + low content similarity = potential contradiction
# contradiction_score: higher = more contradictory
contradiction_score = entity_overlap * (1.0 - (content_sim + 1.0) / 2.0)
if contradiction_score >= threshold:
# Strip hrr_vector from output (not JSON serializable)
f1_clean = {k: v for k, v in f1.items() if k != "hrr_vector"}
f2_clean = {k: v for k, v in f2.items() if k != "hrr_vector"}
contradictions.append({
"fact_a": f1_clean,
"fact_b": f2_clean,
"entity_overlap": round(entity_overlap, 3),
"content_similarity": round(content_sim, 3),
"contradiction_score": round(contradiction_score, 3),
"shared_entities": sorted(ents1 & ents2),
})
contradictions.sort(key=lambda x: x["contradiction_score"], reverse=True)
return contradictions[:limit]
def _score_facts_by_vector(
self,
target_vec: "np.ndarray",
category: str | None = None,
limit: int = 10,
) -> list[dict]:
"""Score facts by similarity to a target vector."""
conn = self.store._conn
where = "WHERE hrr_vector IS NOT NULL"
params: list = []
if category:
where += " AND category = ?"
params.append(category)
rows = conn.execute(
f"""
SELECT fact_id, content, category, tags, trust_score,
retrieval_count, helpful_count, created_at, updated_at,
hrr_vector
FROM facts
{where}
""",
params,
).fetchall()
scored = []
for row in rows:
fact = dict(row)
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"))
sim = hrr.similarity(target_vec, fact_vec)
fact["score"] = (sim + 1.0) / 2.0 * fact["trust_score"]
scored.append(fact)
scored.sort(key=lambda x: x["score"], reverse=True)
return scored[:limit]
def _fts_candidates(
self,
query: str,
category: str | None,
min_trust: float,
limit: int,
) -> list[dict]:
"""Get raw FTS5 candidates from the store.
Uses the store's database connection directly for FTS5 MATCH
with rank scoring. Normalizes FTS5 rank to [0, 1] range.
"""
conn = self.store._conn
# Build query - FTS5 rank is negative (lower = better match)
# We need to join facts_fts with facts to get all columns
params: list = []
where_clauses = ["facts_fts MATCH ?"]
params.append(query)
if category:
where_clauses.append("f.category = ?")
params.append(category)
where_clauses.append("f.trust_score >= ?")
params.append(min_trust)
where_sql = " AND ".join(where_clauses)
sql = f"""
SELECT f.*, facts_fts.rank as fts_rank_raw
FROM facts_fts
JOIN facts f ON f.fact_id = facts_fts.rowid
WHERE {where_sql}
ORDER BY facts_fts.rank
LIMIT ?
"""
params.append(limit)
try:
rows = conn.execute(sql, params).fetchall()
except Exception:
# FTS5 MATCH can fail on malformed queries — fall back to empty
return []
if not rows:
return []
# Normalize FTS5 rank: rank is negative, lower = better
# Convert to positive score in [0, 1] range
raw_ranks = [abs(row["fts_rank_raw"]) for row in rows]
max_rank = max(raw_ranks) if raw_ranks else 1.0
max_rank = max(max_rank, 1e-6) # avoid div by zero
results = []
for row, raw_rank in zip(rows, raw_ranks):
fact = dict(row)
fact.pop("fts_rank_raw", None)
fact["fts_rank"] = raw_rank / max_rank # normalize to [0, 1]
results.append(fact)
return results
@staticmethod
def _tokenize(text: str) -> set[str]:
"""Simple whitespace tokenization with lowercasing.
Strips common punctuation. No stemming/lemmatization (Phase 1).
"""
if not text:
return set()
# Split on whitespace, lowercase, strip punctuation
tokens = set()
for word in text.lower().split():
cleaned = word.strip(".,;:!?\"'()[]{}#@<>")
if cleaned:
tokens.add(cleaned)
return tokens
@staticmethod
def _jaccard_similarity(set_a: set, set_b: set) -> float:
"""Jaccard similarity coefficient: |A ∩ B| / |A B|."""
if not set_a or not set_b:
return 0.0
intersection = len(set_a & set_b)
union = len(set_a | set_b)
return intersection / union if union > 0 else 0.0
def _temporal_decay(self, timestamp_str: str | None) -> float:
"""Exponential decay: 0.5^(age_days / half_life_days).
Returns 1.0 if decay is disabled or timestamp is missing.
"""
if not self.half_life or not timestamp_str:
return 1.0
try:
if isinstance(timestamp_str, str):
# Parse ISO format timestamp from SQLite
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
else:
ts = timestamp_str
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400
if age_days < 0:
return 1.0
return math.pow(0.5, age_days / self.half_life)
except (ValueError, TypeError):
return 1.0
+575
View File
@@ -0,0 +1,575 @@
"""
SQLite-backed fact store with entity resolution and trust scoring.
Single-user Hermes memory store plugin.
"""
import re
import sqlite3
import threading
from datetime import datetime
from pathlib import Path
try:
from . import holographic as hrr
except ImportError:
import holographic as hrr # type: ignore[no-redef]
_SCHEMA = """
CREATE TABLE IF NOT EXISTS facts (
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL UNIQUE,
category TEXT DEFAULT 'general',
tags TEXT DEFAULT '',
trust_score REAL DEFAULT 0.5,
retrieval_count INTEGER DEFAULT 0,
helpful_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
hrr_vector BLOB
);
CREATE TABLE IF NOT EXISTS entities (
entity_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
entity_type TEXT DEFAULT 'unknown',
aliases TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS fact_entities (
fact_id INTEGER REFERENCES facts(fact_id),
entity_id INTEGER REFERENCES entities(entity_id),
PRIMARY KEY (fact_id, entity_id)
);
CREATE INDEX IF NOT EXISTS idx_facts_trust ON facts(trust_score DESC);
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts
USING fts5(content, tags, content=facts, content_rowid=fact_id);
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
INSERT INTO facts_fts(rowid, content, tags)
VALUES (new.fact_id, new.content, new.tags);
END;
CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
INSERT INTO facts_fts(facts_fts, rowid, content, tags)
VALUES ('delete', old.fact_id, old.content, old.tags);
END;
CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
INSERT INTO facts_fts(facts_fts, rowid, content, tags)
VALUES ('delete', old.fact_id, old.content, old.tags);
INSERT INTO facts_fts(rowid, content, tags)
VALUES (new.fact_id, new.content, new.tags);
END;
CREATE TABLE IF NOT EXISTS memory_banks (
bank_id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_name TEXT NOT NULL UNIQUE,
vector BLOB NOT NULL,
dim INTEGER NOT NULL,
fact_count INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
# Trust adjustment constants
_HELPFUL_DELTA = 0.05
_UNHELPFUL_DELTA = -0.10
_TRUST_MIN = 0.0
_TRUST_MAX = 1.0
# Entity extraction patterns
_RE_CAPITALIZED = re.compile(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b')
_RE_DOUBLE_QUOTE = re.compile(r'"([^"]+)"')
_RE_SINGLE_QUOTE = re.compile(r"'([^']+)'")
_RE_AKA = re.compile(
r'(\w+(?:\s+\w+)*)\s+(?:aka|also known as)\s+(\w+(?:\s+\w+)*)',
re.IGNORECASE,
)
def _clamp_trust(value: float) -> float:
return max(_TRUST_MIN, min(_TRUST_MAX, value))
class MemoryStore:
"""SQLite-backed fact store with entity resolution and trust scoring."""
def __init__(
self,
db_path: "str | Path | None" = None,
default_trust: float = 0.5,
hrr_dim: int = 1024,
) -> None:
if db_path is None:
from hermes_constants import get_hermes_home
db_path = str(get_hermes_home() / "memory_store.db")
self.db_path = Path(db_path).expanduser()
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.default_trust = _clamp_trust(default_trust)
self.hrr_dim = hrr_dim
self._hrr_available = hrr._HAS_NUMPY
self._conn: sqlite3.Connection = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
timeout=10.0,
)
self._lock = threading.RLock()
self._conn.row_factory = sqlite3.Row
self._init_db()
# ------------------------------------------------------------------
# Initialisation
# ------------------------------------------------------------------
def _init_db(self) -> None:
"""Create tables, indexes, and triggers if they do not exist. Enable WAL mode."""
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.executescript(_SCHEMA)
# Migrate: add hrr_vector column if missing (safe for existing databases)
columns = {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}
if "hrr_vector" not in columns:
self._conn.execute("ALTER TABLE facts ADD COLUMN hrr_vector BLOB")
self._conn.commit()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def add_fact(
self,
content: str,
category: str = "general",
tags: str = "",
) -> int:
"""Insert a fact and return its fact_id.
Deduplicates by content (UNIQUE constraint). On duplicate, returns
the existing fact_id without modifying the row. Extracts entities from
the content and links them to the fact.
"""
with self._lock:
content = content.strip()
if not content:
raise ValueError("content must not be empty")
try:
cur = self._conn.execute(
"""
INSERT INTO facts (content, category, tags, trust_score)
VALUES (?, ?, ?, ?)
""",
(content, category, tags, self.default_trust),
)
self._conn.commit()
fact_id: int = cur.lastrowid # type: ignore[assignment]
except sqlite3.IntegrityError:
# Duplicate content — return existing id
row = self._conn.execute(
"SELECT fact_id FROM facts WHERE content = ?", (content,)
).fetchone()
return int(row["fact_id"])
# Entity extraction and linking
for name in self._extract_entities(content):
entity_id = self._resolve_entity(name)
self._link_fact_entity(fact_id, entity_id)
# Compute HRR vector after entity linking
self._compute_hrr_vector(fact_id, content)
self._rebuild_bank(category)
return fact_id
def search_facts(
self,
query: str,
category: str | None = None,
min_trust: float = 0.3,
limit: int = 10,
) -> list[dict]:
"""Full-text search over facts using FTS5.
Returns a list of fact dicts ordered by FTS5 rank, then trust_score
descending. Also increments retrieval_count for matched facts.
"""
with self._lock:
query = query.strip()
if not query:
return []
params: list = [query, min_trust]
category_clause = ""
if category is not None:
category_clause = "AND f.category = ?"
params.append(category)
params.append(limit)
sql = f"""
SELECT f.fact_id, f.content, f.category, f.tags,
f.trust_score, f.retrieval_count, f.helpful_count,
f.created_at, f.updated_at
FROM facts f
JOIN facts_fts fts ON fts.rowid = f.fact_id
WHERE facts_fts MATCH ?
AND f.trust_score >= ?
{category_clause}
ORDER BY fts.rank, f.trust_score DESC
LIMIT ?
"""
rows = self._conn.execute(sql, params).fetchall()
results = [self._row_to_dict(r) for r in rows]
if results:
ids = [r["fact_id"] for r in results]
placeholders = ",".join("?" * len(ids))
self._conn.execute(
f"UPDATE facts SET retrieval_count = retrieval_count + 1 WHERE fact_id IN ({placeholders})",
ids,
)
self._conn.commit()
return results
def update_fact(
self,
fact_id: int,
content: str | None = None,
trust_delta: float | None = None,
tags: str | None = None,
category: str | None = None,
) -> bool:
"""Partially update a fact. Trust is clamped to [0, 1].
Returns True if the row existed, False otherwise.
"""
with self._lock:
row = self._conn.execute(
"SELECT fact_id, trust_score FROM facts WHERE fact_id = ?", (fact_id,)
).fetchone()
if row is None:
return False
assignments: list[str] = ["updated_at = CURRENT_TIMESTAMP"]
params: list = []
if content is not None:
assignments.append("content = ?")
params.append(content.strip())
if tags is not None:
assignments.append("tags = ?")
params.append(tags)
if category is not None:
assignments.append("category = ?")
params.append(category)
if trust_delta is not None:
new_trust = _clamp_trust(row["trust_score"] + trust_delta)
assignments.append("trust_score = ?")
params.append(new_trust)
params.append(fact_id)
self._conn.execute(
f"UPDATE facts SET {', '.join(assignments)} WHERE fact_id = ?",
params,
)
self._conn.commit()
# If content changed, re-extract entities
if content is not None:
self._conn.execute(
"DELETE FROM fact_entities WHERE fact_id = ?", (fact_id,)
)
for name in self._extract_entities(content):
entity_id = self._resolve_entity(name)
self._link_fact_entity(fact_id, entity_id)
self._conn.commit()
# Recompute HRR vector if content changed
if content is not None:
self._compute_hrr_vector(fact_id, content)
# Rebuild bank for relevant category
cat = category or self._conn.execute(
"SELECT category FROM facts WHERE fact_id = ?", (fact_id,)
).fetchone()["category"]
self._rebuild_bank(cat)
return True
def remove_fact(self, fact_id: int) -> bool:
"""Delete a fact and its entity links. Returns True if the row existed."""
with self._lock:
row = self._conn.execute(
"SELECT fact_id, category FROM facts WHERE fact_id = ?", (fact_id,)
).fetchone()
if row is None:
return False
self._conn.execute(
"DELETE FROM fact_entities WHERE fact_id = ?", (fact_id,)
)
self._conn.execute("DELETE FROM facts WHERE fact_id = ?", (fact_id,))
self._conn.commit()
self._rebuild_bank(row["category"])
return True
def list_facts(
self,
category: str | None = None,
min_trust: float = 0.0,
limit: int = 50,
) -> list[dict]:
"""Browse facts ordered by trust_score descending.
Optionally filter by category and minimum trust score.
"""
with self._lock:
params: list = [min_trust]
category_clause = ""
if category is not None:
category_clause = "AND category = ?"
params.append(category)
params.append(limit)
sql = f"""
SELECT fact_id, content, category, tags, trust_score,
retrieval_count, helpful_count, created_at, updated_at
FROM facts
WHERE trust_score >= ?
{category_clause}
ORDER BY trust_score DESC
LIMIT ?
"""
rows = self._conn.execute(sql, params).fetchall()
return [self._row_to_dict(r) for r in rows]
def record_feedback(self, fact_id: int, helpful: bool) -> dict:
"""Record user feedback and adjust trust asymmetrically.
helpful=True -> trust += 0.05, helpful_count += 1
helpful=False -> trust -= 0.10
Returns a dict with fact_id, old_trust, new_trust, helpful_count.
Raises KeyError if fact_id does not exist.
"""
with self._lock:
row = self._conn.execute(
"SELECT fact_id, trust_score, helpful_count FROM facts WHERE fact_id = ?",
(fact_id,),
).fetchone()
if row is None:
raise KeyError(f"fact_id {fact_id} not found")
old_trust: float = row["trust_score"]
delta = _HELPFUL_DELTA if helpful else _UNHELPFUL_DELTA
new_trust = _clamp_trust(old_trust + delta)
helpful_increment = 1 if helpful else 0
self._conn.execute(
"""
UPDATE facts
SET trust_score = ?,
helpful_count = helpful_count + ?,
updated_at = CURRENT_TIMESTAMP
WHERE fact_id = ?
""",
(new_trust, helpful_increment, fact_id),
)
self._conn.commit()
return {
"fact_id": fact_id,
"old_trust": old_trust,
"new_trust": new_trust,
"helpful_count": row["helpful_count"] + helpful_increment,
}
# ------------------------------------------------------------------
# Entity helpers
# ------------------------------------------------------------------
def _extract_entities(self, text: str) -> list[str]:
"""Extract entity candidates from text using simple regex rules.
Rules applied (in order):
1. Capitalized multi-word phrases e.g. "John Doe"
2. Double-quoted terms e.g. "Python"
3. Single-quoted terms e.g. 'pytest'
4. AKA patterns e.g. "Guido aka BDFL" -> two entities
Returns a deduplicated list preserving first-seen order.
"""
seen: set[str] = set()
candidates: list[str] = []
def _add(name: str) -> None:
stripped = name.strip()
if stripped and stripped.lower() not in seen:
seen.add(stripped.lower())
candidates.append(stripped)
for m in _RE_CAPITALIZED.finditer(text):
_add(m.group(1))
for m in _RE_DOUBLE_QUOTE.finditer(text):
_add(m.group(1))
for m in _RE_SINGLE_QUOTE.finditer(text):
_add(m.group(1))
for m in _RE_AKA.finditer(text):
_add(m.group(1))
_add(m.group(2))
return candidates
def _resolve_entity(self, name: str) -> int:
"""Find an existing entity by name or alias (case-insensitive) or create one.
Returns the entity_id.
"""
# Exact name match
row = self._conn.execute(
"SELECT entity_id FROM entities WHERE name LIKE ?", (name,)
).fetchone()
if row is not None:
return int(row["entity_id"])
# Search aliases — aliases stored as comma-separated; use LIKE with % boundaries
alias_row = self._conn.execute(
"""
SELECT entity_id FROM entities
WHERE ',' || aliases || ',' LIKE '%,' || ? || ',%'
""",
(name,),
).fetchone()
if alias_row is not None:
return int(alias_row["entity_id"])
# Create new entity
cur = self._conn.execute(
"INSERT INTO entities (name) VALUES (?)", (name,)
)
self._conn.commit()
return int(cur.lastrowid) # type: ignore[return-value]
def _link_fact_entity(self, fact_id: int, entity_id: int) -> None:
"""Insert into fact_entities, silently ignore if the link already exists."""
self._conn.execute(
"""
INSERT OR IGNORE INTO fact_entities (fact_id, entity_id)
VALUES (?, ?)
""",
(fact_id, entity_id),
)
self._conn.commit()
def _compute_hrr_vector(self, fact_id: int, content: str) -> None:
"""Compute and store HRR vector for a fact. No-op if numpy unavailable."""
with self._lock:
if not self._hrr_available:
return
# Get entities linked to this fact
rows = self._conn.execute(
"""
SELECT e.name FROM entities e
JOIN fact_entities fe ON fe.entity_id = e.entity_id
WHERE fe.fact_id = ?
""",
(fact_id,),
).fetchall()
entities = [row["name"] for row in rows]
vector = hrr.encode_fact(content, entities, self.hrr_dim)
self._conn.execute(
"UPDATE facts SET hrr_vector = ? WHERE fact_id = ?",
(hrr.phases_to_bytes(vector), fact_id),
)
self._conn.commit()
def _rebuild_bank(self, category: str) -> None:
"""Full rebuild of a category's memory bank from all its fact vectors."""
with self._lock:
if not self._hrr_available:
return
bank_name = f"cat:{category}"
rows = self._conn.execute(
"SELECT hrr_vector FROM facts WHERE category = ? AND hrr_vector IS NOT NULL",
(category,),
).fetchall()
if not rows:
self._conn.execute("DELETE FROM memory_banks WHERE bank_name = ?", (bank_name,))
self._conn.commit()
return
vectors = [hrr.bytes_to_phases(row["hrr_vector"]) for row in rows]
bank_vector = hrr.bundle(*vectors)
fact_count = len(vectors)
# Check SNR
hrr.snr_estimate(self.hrr_dim, fact_count)
self._conn.execute(
"""
INSERT INTO memory_banks (bank_name, vector, dim, fact_count, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(bank_name) DO UPDATE SET
vector = excluded.vector,
dim = excluded.dim,
fact_count = excluded.fact_count,
updated_at = excluded.updated_at
""",
(bank_name, hrr.phases_to_bytes(bank_vector), self.hrr_dim, fact_count),
)
self._conn.commit()
def rebuild_all_vectors(self, dim: int | None = None) -> int:
"""Recompute all HRR vectors + banks from text. For recovery/migration.
Returns the number of facts processed.
"""
with self._lock:
if not self._hrr_available:
return 0
if dim is not None:
self.hrr_dim = dim
rows = self._conn.execute(
"SELECT fact_id, content, category FROM facts"
).fetchall()
categories: set[str] = set()
for row in rows:
self._compute_hrr_vector(row["fact_id"], row["content"])
categories.add(row["category"])
for category in categories:
self._rebuild_bank(category)
return len(rows)
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def _row_to_dict(self, row: sqlite3.Row) -> dict:
"""Convert a sqlite3.Row to a plain dict."""
return dict(row)
def close(self) -> None:
"""Close the database connection."""
self._conn.close()
def __enter__(self) -> "MemoryStore":
return self
def __exit__(self, *_: object) -> None:
self.close()
+220
View File
@@ -0,0 +1,220 @@
# Honcho Memory Provider
AI-native cross-session user modeling with dialectic Q&A, semantic search, peer cards, and persistent conclusions.
> **Honcho docs:** <https://docs.honcho.dev/v3/guides/integrations/hermes>
## Requirements
- `pip install honcho-ai`
- Honcho API key from [app.honcho.dev](https://app.honcho.dev), or a self-hosted instance
## Setup
```bash
hermes honcho setup # full interactive wizard (cloud or local)
hermes memory setup # generic picker, also works
```
Or manually:
```bash
hermes config set memory.provider honcho
echo "HONCHO_API_KEY=your-key" >> ~/.hermes/.env
```
## Config Resolution
Config is read from the first file that exists:
| Priority | Path | Scope |
|----------|------|-------|
| 1 | `$HERMES_HOME/honcho.json` | Profile-local (isolated Hermes instances) |
| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
| 3 | `~/.honcho/config.json` | Global (cross-app interop) |
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`.
## Tools
| Tool | LLM call? | Description |
|------|-----------|-------------|
| `honcho_profile` | No | User's peer card -- key facts snapshot |
| `honcho_search` | No | Semantic search over stored context (800 tok default, 2000 max) |
| `honcho_context` | Yes | LLM-synthesized answer via dialectic reasoning |
| `honcho_conclude` | No | Write a persistent fact about the user |
Tool availability depends on `recallMode`: hidden in `context` mode, always present in `tools` and `hybrid`.
## Full Configuration Reference
### Identity & Connection
| Key | Type | Default | Scope | Description |
|-----|------|---------|-------|-------------|
| `apiKey` | string | -- | root / host | API key. Falls back to `HONCHO_API_KEY` env var |
| `baseUrl` | string | -- | root | Base URL for self-hosted Honcho. Local URLs (`localhost`, `127.0.0.1`, `::1`) auto-skip API key auth |
| `environment` | string | `"production"` | root / host | SDK environment mapping |
| `enabled` | bool | auto | root / host | Master toggle. Auto-enables when `apiKey` or `baseUrl` present |
| `workspace` | string | host key | root / host | Honcho workspace ID |
| `peerName` | string | -- | root / host | User peer identity |
| `aiPeer` | string | host key | root / host | AI peer identity |
### Memory & Recall
| Key | Type | Default | Scope | Description |
|-----|------|---------|-------|-------------|
| `recallMode` | string | `"hybrid"` | root / host | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` normalizes to `"hybrid"` |
| `observationMode` | string | `"directional"` | root / host | Shorthand preset: `"directional"` (all on) or `"unified"` (shared pool). Use `observation` object for granular control |
| `observation` | object | -- | root / host | Per-peer observation config (see below) |
#### Observation (granular)
Maps 1:1 to Honcho's per-peer `SessionPeerConfig`. Set at root or per host block -- each profile can have different observation settings. When present, overrides `observationMode` preset.
```json
"observation": {
"user": { "observeMe": true, "observeOthers": true },
"ai": { "observeMe": true, "observeOthers": true }
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `user.observeMe` | `true` | User peer self-observation (Honcho builds user representation) |
| `user.observeOthers` | `true` | User peer observes AI messages |
| `ai.observeMe` | `true` | AI peer self-observation (Honcho builds AI representation) |
| `ai.observeOthers` | `true` | AI peer observes user messages (enables cross-peer dialectic) |
Presets for `observationMode`:
- `"directional"` (default): all four booleans `true`
- `"unified"`: user `observeMe=true`, AI `observeOthers=true`, rest `false`
Per-profile example -- coder profile observes the user but user doesn't observe coder:
```json
"hosts": {
"hermes.coder": {
"observation": {
"user": { "observeMe": true, "observeOthers": false },
"ai": { "observeMe": true, "observeOthers": true }
}
}
}
```
Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced back on session init.
### Write Behavior
| Key | Type | Default | Scope | Description |
|-----|------|---------|-------|-------------|
| `writeFrequency` | string or int | `"async"` | root / host | `"async"` (background thread), `"turn"` (sync per turn), `"session"` (batch on end), or integer N (every N turns) |
| `saveMessages` | bool | `true` | root / host | Whether to persist messages to Honcho API |
### Session Resolution
| Key | Type | Default | Scope | Description |
|-----|------|---------|-------|-------------|
| `sessionStrategy` | string | `"per-directory"` | root / host | `"per-directory"`, `"per-session"` (new each run), `"per-repo"` (git root name), `"global"` (single session) |
| `sessionPeerPrefix` | bool | `false` | root / host | Prepend peer name to session keys |
| `sessions` | object | `{}` | root | Manual directory-to-session-name mappings: `{"/path/to/project": "my-session"}` |
### Token Budgets & Dialectic
| Key | Type | Default | Scope | Description |
|-----|------|---------|-------|-------------|
| `contextTokens` | int | SDK default | root / host | Token budget for `context()` API calls. Also gates prefetch truncation (tokens x 4 chars) |
| `dialecticReasoningLevel` | string | `"low"` | root / host | Base reasoning level for `peer.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
| `dialecticDynamic` | bool | `true` | root / host | Auto-bump reasoning based on query length: `<120` chars = base level, `120-400` = +1, `>400` = +2 (capped at `"high"`). Set `false` to always use `dialecticReasoningLevel` as-is |
| `dialecticMaxChars` | int | `600` | root / host | Max chars of dialectic result injected into system prompt |
| `dialecticMaxInputChars` | int | `10000` | root / host | Max chars for dialectic query input to `peer.chat()`. Honcho cloud limit: 10k |
| `messageMaxChars` | int | `25000` | root / host | Max chars per message sent via `add_messages()`. Messages exceeding this are chunked with `[continued]` markers. Honcho cloud limit: 25k |
### Cost Awareness (Advanced)
These are read from the root config object, not the host block. Must be set manually in `honcho.json`.
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context only on turn 0) |
| `contextCadence` | int | `1` | Minimum turns between `context()` API calls |
| `dialecticCadence` | int | `1` | Minimum turns between `peer.chat()` API calls |
| `reasoningLevelCap` | string | -- | Hard cap on auto-bumped reasoning: `"minimal"`, `"low"`, `"mid"`, `"high"` |
### Hardcoded Limits (Not Configurable)
| Limit | Value | Location |
|-------|-------|----------|
| Search tool max tokens | 2000 (hard cap), 800 (default) | `__init__.py` handle_tool_call |
| Peer card fetch tokens | 200 | `session.py` get_peer_card |
## Config Precedence
For every key, resolution order is: **host block > root > env var > default**.
Host key derivation: `HERMES_HONCHO_HOST` env > active profile (`hermes.<profile>`) > `"hermes"`.
## Environment Variables
| Variable | Fallback for |
|----------|-------------|
| `HONCHO_API_KEY` | `apiKey` |
| `HONCHO_BASE_URL` | `baseUrl` |
| `HONCHO_ENVIRONMENT` | `environment` |
| `HERMES_HONCHO_HOST` | Host key override |
## CLI Commands
| Command | Description |
|---------|-------------|
| `hermes honcho setup` | Full interactive setup wizard |
| `hermes honcho status` | Show resolved config for active profile |
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
| `hermes honcho mode <mode>` | Change recall or observation mode |
| `hermes honcho peer --user <name>` | Update user peer name |
| `hermes honcho peer --ai <name>` | Update AI peer name |
| `hermes honcho tokens --context <N>` | Set context token budget |
| `hermes honcho tokens --dialectic <N>` | Set dialectic max chars |
| `hermes honcho map <name>` | Map current directory to a session name |
| `hermes honcho sync` | Create host blocks for all Hermes profiles |
## Example Config
```json
{
"apiKey": "your-key",
"workspace": "hermes",
"peerName": "eri",
"hosts": {
"hermes": {
"enabled": true,
"aiPeer": "hermes",
"workspace": "hermes",
"peerName": "eri",
"recallMode": "hybrid",
"observation": {
"user": { "observeMe": true, "observeOthers": true },
"ai": { "observeMe": true, "observeOthers": true }
},
"writeFrequency": "async",
"sessionStrategy": "per-directory",
"dialecticReasoningLevel": "low",
"dialecticMaxChars": 600,
"saveMessages": true
},
"hermes.coder": {
"enabled": true,
"aiPeer": "coder",
"workspace": "hermes",
"peerName": "eri",
"observation": {
"user": { "observeMe": true, "observeOthers": false },
"ai": { "observeMe": true, "observeOthers": true }
}
}
},
"sessions": {
"/home/user/myproject": "myproject-main"
}
}
```
+708
View File
@@ -0,0 +1,708 @@
"""Honcho memory plugin — MemoryProvider for Honcho AI-native memory.
Provides cross-session user modeling with dialectic Q&A, semantic search,
peer cards, and persistent conclusions via the Honcho SDK. Honcho provides AI-native cross-session user
modeling with dialectic Q&A, semantic search, peer cards, and conclusions.
The 4 tools (profile, search, context, conclude) are exposed through
the MemoryProvider interface.
Config: Uses the existing Honcho config chain:
1. $HERMES_HOME/honcho.json (profile-scoped)
2. ~/.honcho/config.json (legacy global)
3. Environment variables
"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool schemas (moved from tools/honcho_tools.py)
# ---------------------------------------------------------------------------
PROFILE_SCHEMA = {
"name": "honcho_profile",
"description": (
"Retrieve the user's peer card from Honcho — a curated list of key facts "
"about them (name, role, preferences, communication style, patterns). "
"Fast, no LLM reasoning, minimal cost. "
"Use this at conversation start or when you need a quick factual snapshot."
),
"parameters": {"type": "object", "properties": {}, "required": []},
}
SEARCH_SCHEMA = {
"name": "honcho_search",
"description": (
"Semantic search over Honcho's stored context about the user. "
"Returns raw excerpts ranked by relevance — no LLM synthesis. "
"Cheaper and faster than honcho_context. "
"Good when you want to find specific past facts and reason over them yourself."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to search for in Honcho's memory.",
},
"max_tokens": {
"type": "integer",
"description": "Token budget for returned context (default 800, max 2000).",
},
},
"required": ["query"],
},
}
CONTEXT_SCHEMA = {
"name": "honcho_context",
"description": (
"Ask Honcho a natural language question and get a synthesized answer. "
"Uses Honcho's LLM (dialectic reasoning) — higher cost than honcho_profile or honcho_search. "
"Can query about any peer: the user (default) or the AI assistant."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A natural language question.",
},
"peer": {
"type": "string",
"description": "Which peer to query about: 'user' (default) or 'ai'.",
},
},
"required": ["query"],
},
}
CONCLUDE_SCHEMA = {
"name": "honcho_conclude",
"description": (
"Write a conclusion about the user back to Honcho's memory. "
"Conclusions are persistent facts that build the user's profile. "
"Use when the user states a preference, corrects you, or shares "
"something to remember across sessions."
),
"parameters": {
"type": "object",
"properties": {
"conclusion": {
"type": "string",
"description": "A factual statement about the user to persist.",
}
},
"required": ["conclusion"],
},
}
ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA]
# ---------------------------------------------------------------------------
# MemoryProvider implementation
# ---------------------------------------------------------------------------
class HonchoMemoryProvider(MemoryProvider):
"""Honcho AI-native memory with dialectic Q&A and persistent user modeling."""
def __init__(self):
self._manager = None # HonchoSessionManager
self._config = None # HonchoClientConfig
self._session_key = ""
self._prefetch_result = ""
self._prefetch_lock = threading.Lock()
self._prefetch_thread: Optional[threading.Thread] = None
self._sync_thread: Optional[threading.Thread] = None
# B1: recall_mode — set during initialize from config
self._recall_mode = "hybrid" # "context", "tools", or "hybrid"
# B4: First-turn context baking
self._first_turn_context: Optional[str] = None
self._first_turn_lock = threading.Lock()
# B5: Cost-awareness turn counting and cadence
self._turn_count = 0
self._injection_frequency = "every-turn" # or "first-turn"
self._context_cadence = 1 # minimum turns between context API calls
self._dialectic_cadence = 1 # minimum turns between dialectic API calls
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "mid", "high"
self._last_context_turn = -999
self._last_dialectic_turn = -999
# Port #1957: lazy session init for tools-only mode
self._session_initialized = False
self._lazy_init_kwargs: Optional[dict] = None
self._lazy_init_session_id: Optional[str] = None
# Port #4053: cron guard — when True, plugin is fully inactive
self._cron_skipped = False
@property
def name(self) -> str:
return "honcho"
def is_available(self) -> bool:
"""Check if Honcho is configured. No network calls."""
try:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
# Port #2645: baseUrl-only verification — api_key OR base_url suffices
return cfg.enabled and bool(cfg.api_key or cfg.base_url)
except Exception:
return False
def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/honcho.json (Honcho SDK native format)."""
import json
from pathlib import Path
config_path = Path(hermes_home) / "honcho.json"
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
except Exception:
pass
existing.update(values)
config_path.write_text(json.dumps(existing, indent=2))
def get_config_schema(self):
return [
{"key": "api_key", "description": "Honcho API key", "secret": True, "env_var": "HONCHO_API_KEY", "url": "https://app.honcho.dev"},
{"key": "baseUrl", "description": "Honcho base URL (for self-hosted)"},
]
def post_setup(self, hermes_home: str, config: dict) -> None:
"""Run the full Honcho setup wizard after provider selection."""
import types
from plugins.memory.honcho.cli import cmd_setup
cmd_setup(types.SimpleNamespace())
def initialize(self, session_id: str, **kwargs) -> None:
"""Initialize Honcho session manager.
Handles: cron guard, recall_mode, session name resolution,
peer memory mode, SOUL.md ai_peer sync, memory file migration,
and pre-warming context at init.
"""
try:
# ----- Port #4053: cron guard -----
agent_context = kwargs.get("agent_context", "")
platform = kwargs.get("platform", "cli")
if agent_context in ("cron", "flush") or platform == "cron":
logger.debug("Honcho skipped: cron/flush context (agent_context=%s, platform=%s)",
agent_context, platform)
self._cron_skipped = True
return
from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client
from plugins.memory.honcho.session import HonchoSessionManager
cfg = HonchoClientConfig.from_global_config()
if not cfg.enabled or not (cfg.api_key or cfg.base_url):
logger.debug("Honcho not configured — plugin inactive")
return
self._config = cfg
# ----- B1: recall_mode from config -----
self._recall_mode = cfg.recall_mode # "context", "tools", or "hybrid"
logger.debug("Honcho recall_mode: %s", self._recall_mode)
# ----- B5: cost-awareness config -----
try:
raw = cfg.raw or {}
self._injection_frequency = raw.get("injectionFrequency", "every-turn")
self._context_cadence = int(raw.get("contextCadence", 1))
self._dialectic_cadence = int(raw.get("dialecticCadence", 1))
cap = raw.get("reasoningLevelCap")
if cap and cap in ("minimal", "low", "mid", "high"):
self._reasoning_level_cap = cap
except Exception as e:
logger.debug("Honcho cost-awareness config parse error: %s", e)
# ----- Port #1969: aiPeer sync from SOUL.md — REMOVED -----
# SOUL.md is persona content, not identity config. aiPeer should
# only come from honcho.json (host block or root) or the default.
# See scratch/memory-plugin-ux-specs.md #10 for rationale.
# ----- Port #1957: lazy session init for tools-only mode -----
if self._recall_mode == "tools":
# Defer actual session creation until first tool call
self._lazy_init_kwargs = kwargs
self._lazy_init_session_id = session_id
# Still need a client reference for _ensure_session
self._config = cfg
logger.debug("Honcho tools-only mode — deferring session init until first tool call")
return
# ----- Eager init (context or hybrid mode) -----
self._do_session_init(cfg, session_id, **kwargs)
except ImportError:
logger.debug("honcho-ai package not installed — plugin inactive")
except Exception as e:
logger.warning("Honcho init failed: %s", e)
self._manager = None
def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
"""Shared session initialization logic for both eager and lazy paths."""
from plugins.memory.honcho.client import get_honcho_client
from plugins.memory.honcho.session import HonchoSessionManager
client = get_honcho_client(cfg)
self._manager = HonchoSessionManager(
honcho=client,
config=cfg,
context_tokens=cfg.context_tokens,
)
# ----- B3: resolve_session_name -----
session_title = kwargs.get("session_title")
self._session_key = (
cfg.resolve_session_name(session_title=session_title, session_id=session_id)
or session_id
or "hermes-default"
)
logger.debug("Honcho session key resolved: %s", self._session_key)
# Create session eagerly
session = self._manager.get_or_create(self._session_key)
self._session_initialized = True
# ----- B6: Memory file migration (one-time, for new sessions) -----
try:
if not session.messages:
from hermes_constants import get_hermes_home
mem_dir = str(get_hermes_home() / "memories")
self._manager.migrate_memory_files(self._session_key, mem_dir)
logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key)
except Exception as e:
logger.debug("Honcho memory file migration skipped: %s", e)
# ----- B7: Pre-warming context at init -----
if self._recall_mode in ("context", "hybrid"):
try:
self._manager.prefetch_context(self._session_key)
self._manager.prefetch_dialectic(self._session_key, "What should I know about this user?")
logger.debug("Honcho pre-warm threads started for session: %s", self._session_key)
except Exception as e:
logger.debug("Honcho pre-warm failed: %s", e)
def _ensure_session(self) -> bool:
"""Lazily initialize the Honcho session (for tools-only mode).
Returns True if the manager is ready, False otherwise.
"""
if self._manager and self._session_initialized:
return True
if self._cron_skipped:
return False
if not self._config or not self._lazy_init_kwargs:
return False
try:
self._do_session_init(
self._config,
self._lazy_init_session_id or "hermes-default",
**self._lazy_init_kwargs,
)
# Clear lazy refs
self._lazy_init_kwargs = None
self._lazy_init_session_id = None
return self._manager is not None
except Exception as e:
logger.warning("Honcho lazy session init failed: %s", e)
return False
def _format_first_turn_context(self, ctx: dict) -> str:
"""Format the prefetch context dict into a readable system prompt block."""
parts = []
rep = ctx.get("representation", "")
if rep:
parts.append(f"## User Representation\n{rep}")
card = ctx.get("card", "")
if card:
parts.append(f"## User Peer Card\n{card}")
ai_rep = ctx.get("ai_representation", "")
if ai_rep:
parts.append(f"## AI Self-Representation\n{ai_rep}")
ai_card = ctx.get("ai_card", "")
if ai_card:
parts.append(f"## AI Identity Card\n{ai_card}")
if not parts:
return ""
return "\n\n".join(parts)
def system_prompt_block(self) -> str:
"""Return system prompt text, adapted by recall_mode.
B4: On the FIRST call, fetch and bake the full Honcho context
(user representation, peer card, AI representation, continuity synthesis).
Subsequent calls return the cached block for prompt caching stability.
"""
if self._cron_skipped:
return ""
if not self._manager or not self._session_key:
# tools-only mode without session yet still returns a minimal block
if self._recall_mode == "tools" and self._config:
return (
"# Honcho Memory\n"
"Active (tools-only mode). Use honcho_profile, honcho_search, "
"honcho_context, and honcho_conclude tools to access user memory."
)
return ""
# ----- B4: First-turn context baking -----
first_turn_block = ""
if self._recall_mode in ("context", "hybrid"):
with self._first_turn_lock:
if self._first_turn_context is None:
# First call — fetch and cache
try:
ctx = self._manager.get_prefetch_context(self._session_key)
self._first_turn_context = self._format_first_turn_context(ctx) if ctx else ""
except Exception as e:
logger.debug("Honcho first-turn context fetch failed: %s", e)
self._first_turn_context = ""
first_turn_block = self._first_turn_context
# ----- B1: adapt text based on recall_mode -----
if self._recall_mode == "context":
header = (
"# Honcho Memory\n"
"Active (context-injection mode). Relevant user context is automatically "
"injected before each turn. No memory tools are available — context is "
"managed automatically."
)
elif self._recall_mode == "tools":
header = (
"# Honcho Memory\n"
"Active (tools-only mode). Use honcho_profile for a quick factual snapshot, "
"honcho_search for raw excerpts, honcho_context for synthesized answers, "
"honcho_conclude to save facts about the user. "
"No automatic context injection — you must use tools to access memory."
)
else: # hybrid
header = (
"# Honcho Memory\n"
"Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. "
"Use honcho_profile for a quick factual snapshot, "
"honcho_search for raw excerpts, honcho_context for synthesized answers, "
"honcho_conclude to save facts about the user."
)
if first_turn_block:
return f"{header}\n\n{first_turn_block}"
return header
def prefetch(self, query: str, *, session_id: str = "") -> str:
"""Return prefetched dialectic context from background thread.
B1: Returns empty when recall_mode is "tools" (no injection).
B5: Respects injection_frequency "first-turn" returns cached/empty after turn 0.
Port #3265: Truncates to context_tokens budget.
"""
if self._cron_skipped:
return ""
# B1: tools-only mode — no auto-injection
if self._recall_mode == "tools":
return ""
# B5: injection_frequency — if "first-turn" and past first turn, return empty
if self._injection_frequency == "first-turn" and self._turn_count > 0:
return ""
if self._prefetch_thread and self._prefetch_thread.is_alive():
self._prefetch_thread.join(timeout=3.0)
with self._prefetch_lock:
result = self._prefetch_result
self._prefetch_result = ""
if not result:
return ""
# ----- Port #3265: token budget enforcement -----
result = self._truncate_to_budget(result)
return f"## Honcho Context\n{result}"
def _truncate_to_budget(self, text: str) -> str:
"""Truncate text to fit within context_tokens budget if set."""
if not self._config or not self._config.context_tokens:
return text
budget_chars = self._config.context_tokens * 4 # conservative char estimate
if len(text) <= budget_chars:
return text
# Truncate at word boundary
truncated = text[:budget_chars]
last_space = truncated.rfind(" ")
if last_space > budget_chars * 0.8:
truncated = truncated[:last_space]
return truncated + ""
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
"""Fire a background dialectic query for the upcoming turn.
B5: Checks cadence before firing background threads.
"""
if self._cron_skipped:
return
if not self._manager or not self._session_key or not query:
return
# B1: tools-only mode — no prefetch
if self._recall_mode == "tools":
return
# B5: cadence check — skip if too soon since last dialectic call
if self._dialectic_cadence > 1:
if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence:
logger.debug("Honcho dialectic prefetch skipped: cadence %d, turns since last: %d",
self._dialectic_cadence, self._turn_count - self._last_dialectic_turn)
return
self._last_dialectic_turn = self._turn_count
def _run():
try:
result = self._manager.dialectic_query(
self._session_key, query, peer="user"
)
if result and result.strip():
with self._prefetch_lock:
self._prefetch_result = result
except Exception as e:
logger.debug("Honcho prefetch failed: %s", e)
self._prefetch_thread = threading.Thread(
target=_run, daemon=True, name="honcho-prefetch"
)
self._prefetch_thread.start()
# Also fire context prefetch if cadence allows
if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence:
self._last_context_turn = self._turn_count
try:
self._manager.prefetch_context(self._session_key, query)
except Exception as e:
logger.debug("Honcho context prefetch failed: %s", e)
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
"""Track turn count for cadence and injection_frequency logic."""
self._turn_count = turn_number
@staticmethod
def _chunk_message(content: str, limit: int) -> list[str]:
"""Split content into chunks that fit within the Honcho message limit.
Splits at paragraph boundaries when possible, falling back to
sentence boundaries, then word boundaries. Each continuation
chunk is prefixed with "[continued] " so Honcho's representation
engine can reconstruct the full message.
"""
if len(content) <= limit:
return [content]
prefix = "[continued] "
prefix_len = len(prefix)
chunks = []
remaining = content
first = True
while remaining:
effective = limit if first else limit - prefix_len
if len(remaining) <= effective:
chunks.append(remaining if first else prefix + remaining)
break
segment = remaining[:effective]
# Try paragraph break, then sentence, then word
cut = segment.rfind("\n\n")
if cut < effective * 0.3:
cut = segment.rfind(". ")
if cut >= 0:
cut += 2 # include the period and space
if cut < effective * 0.3:
cut = segment.rfind(" ")
if cut < effective * 0.3:
cut = effective # hard cut
chunk = remaining[:cut].rstrip()
remaining = remaining[cut:].lstrip()
if not first:
chunk = prefix + chunk
chunks.append(chunk)
first = False
return chunks
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
"""Record the conversation turn in Honcho (non-blocking).
Messages exceeding the Honcho API limit (default 25k chars) are
split into multiple messages with continuation markers.
"""
if self._cron_skipped:
return
if not self._manager or not self._session_key:
return
msg_limit = self._config.message_max_chars if self._config else 25000
def _sync():
try:
session = self._manager.get_or_create(self._session_key)
for chunk in self._chunk_message(user_content, msg_limit):
session.add_message("user", chunk)
for chunk in self._chunk_message(assistant_content, msg_limit):
session.add_message("assistant", chunk)
self._manager._flush_session(session)
except Exception as e:
logger.debug("Honcho sync_turn failed: %s", e)
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(
target=_sync, daemon=True, name="honcho-sync"
)
self._sync_thread.start()
def on_memory_write(self, action: str, target: str, content: str) -> None:
"""Mirror built-in user profile writes as Honcho conclusions."""
if action != "add" or target != "user" or not content:
return
if self._cron_skipped:
return
if not self._manager or not self._session_key:
return
def _write():
try:
self._manager.create_conclusion(self._session_key, content)
except Exception as e:
logger.debug("Honcho memory mirror failed: %s", e)
t = threading.Thread(target=_write, daemon=True, name="honcho-memwrite")
t.start()
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
"""Flush all pending messages to Honcho on session end."""
if self._cron_skipped:
return
if not self._manager:
return
# Wait for pending sync
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=10.0)
try:
self._manager.flush_all()
except Exception as e:
logger.debug("Honcho session-end flush failed: %s", e)
def get_tool_schemas(self) -> List[Dict[str, Any]]:
"""Return tool schemas, respecting recall_mode.
B1: context-only mode hides all tools.
"""
if self._cron_skipped:
return []
if self._recall_mode == "context":
return []
return list(ALL_TOOL_SCHEMAS)
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
"""Handle a Honcho tool call, with lazy session init for tools-only mode."""
if self._cron_skipped:
return json.dumps({"error": "Honcho is not active (cron context)."})
# Port #1957: ensure session is initialized for tools-only mode
if not self._session_initialized:
if not self._ensure_session():
return json.dumps({"error": "Honcho session could not be initialized."})
if not self._manager or not self._session_key:
return json.dumps({"error": "Honcho is not active for this session."})
try:
if tool_name == "honcho_profile":
card = self._manager.get_peer_card(self._session_key)
if not card:
return json.dumps({"result": "No profile facts available yet."})
return json.dumps({"result": card})
elif tool_name == "honcho_search":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
max_tokens = min(int(args.get("max_tokens", 800)), 2000)
result = self._manager.search_context(
self._session_key, query, max_tokens=max_tokens
)
if not result:
return json.dumps({"result": "No relevant context found."})
return json.dumps({"result": result})
elif tool_name == "honcho_context":
query = args.get("query", "")
if not query:
return json.dumps({"error": "Missing required parameter: query"})
peer = args.get("peer", "user")
result = self._manager.dialectic_query(
self._session_key, query, peer=peer
)
return json.dumps({"result": result or "No result from Honcho."})
elif tool_name == "honcho_conclude":
conclusion = args.get("conclusion", "")
if not conclusion:
return json.dumps({"error": "Missing required parameter: conclusion"})
ok = self._manager.create_conclusion(self._session_key, conclusion)
if ok:
return json.dumps({"result": f"Conclusion saved: {conclusion}"})
return json.dumps({"error": "Failed to save conclusion."})
return json.dumps({"error": f"Unknown tool: {tool_name}"})
except Exception as e:
logger.error("Honcho tool %s failed: %s", tool_name, e)
return json.dumps({"error": f"Honcho {tool_name} failed: {e}"})
def shutdown(self) -> None:
for t in (self._prefetch_thread, self._sync_thread):
if t and t.is_alive():
t.join(timeout=5.0)
# Flush any remaining messages
if self._manager:
try:
self._manager.flush_all()
except Exception:
pass
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
def register(ctx) -> None:
"""Register Honcho as a memory provider plugin."""
ctx.register_memory_provider(HonchoMemoryProvider())
@@ -11,9 +11,231 @@ import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST
HOST = "hermes"
def clone_honcho_for_profile(profile_name: str) -> bool:
"""Auto-clone Honcho config for a new profile from the default host block.
Called during profile creation. If Honcho is configured on the default
host, creates a new host block for the profile with inherited settings
and auto-derived workspace/aiPeer.
Returns True if a host block was created, False if Honcho isn't configured.
"""
cfg = _read_config()
if not cfg:
return False
hosts = cfg.get("hosts", {})
default_block = hosts.get(HOST, {})
# No default host block and no root-level API key = Honcho not configured
has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
if not default_block and not has_key:
return False
new_host = f"{HOST}.{profile_name}"
if new_host in hosts:
return False # already exists
# Clone settings from default block, override identity fields
new_block = {}
for key in ("recallMode", "writeFrequency", "sessionStrategy",
"sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel",
"dialecticDynamic", "dialecticMaxChars", "messageMaxChars",
"dialecticMaxInputChars", "saveMessages", "observation"):
val = default_block.get(key)
if val is not None:
new_block[key] = val
# Inherit peer name from default
peer_name = default_block.get("peerName") or cfg.get("peerName")
if peer_name:
new_block["peerName"] = peer_name
# AI peer is profile-specific; workspace is shared so all profiles
# see the same user context, sessions, and project history.
# Use the bare profile name as the peer identity (not the host key)
# because Honcho's peer ID pattern is ^[a-zA-Z0-9_-]+$ (no dots).
new_block["aiPeer"] = profile_name
new_block["workspace"] = default_block.get("workspace") or cfg.get("workspace") or HOST
new_block["enabled"] = default_block.get("enabled", True)
cfg.setdefault("hosts", {})[new_host] = new_block
_write_config(cfg)
# Eagerly create the peer in Honcho so it exists before first message
_ensure_peer_exists(new_host)
return True
def _ensure_peer_exists(host_key: str | None = None) -> bool:
"""Create the AI peer in Honcho if it doesn't already exist.
Idempotent -- safe to call multiple times. Returns True if the peer
was created or already exists, False on failure.
"""
try:
from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client
hcfg = HonchoClientConfig.from_global_config(host=host_key)
if not hcfg.enabled or not (hcfg.api_key or hcfg.base_url):
return False
client = get_honcho_client(hcfg)
# peer() is idempotent -- creates if missing, returns if exists
client.peer(hcfg.ai_peer)
if hcfg.peer_name:
client.peer(hcfg.peer_name)
return True
except Exception:
return False
def cmd_enable(args) -> None:
"""Enable Honcho for the active profile."""
cfg = _read_config()
host = _host_key()
label = f"[{host}] " if host != "hermes" else ""
block = cfg.setdefault("hosts", {}).setdefault(host, {})
if block.get("enabled") is True:
print(f" {label}Honcho is already enabled.\n")
return
block["enabled"] = True
# If this is a new profile host block with no settings, clone from default
if not block.get("aiPeer"):
default_block = cfg.get("hosts", {}).get(HOST, {})
for key in ("recallMode", "writeFrequency", "sessionStrategy",
"contextTokens", "dialecticReasoningLevel", "dialecticDynamic",
"dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars",
"saveMessages", "observation"):
val = default_block.get(key)
if val is not None and key not in block:
block[key] = val
peer_name = default_block.get("peerName") or cfg.get("peerName")
if peer_name and "peerName" not in block:
block["peerName"] = peer_name
# Use bare profile name as AI peer, not the host key
ai_peer = host.split(".", 1)[1] if "." in host else host
block.setdefault("aiPeer", ai_peer)
block.setdefault("workspace", default_block.get("workspace") or cfg.get("workspace") or HOST)
_write_config(cfg)
print(f" {label}Honcho enabled.")
# Create peer eagerly
if _ensure_peer_exists(host):
print(f" {label}Peer '{block.get('aiPeer', host)}' ready.")
else:
print(f" {label}Peer creation deferred (no connection).")
print(f" Saved to {_config_path()}\n")
def cmd_disable(args) -> None:
"""Disable Honcho for the active profile."""
cfg = _read_config()
host = _host_key()
label = f"[{host}] " if host != "hermes" else ""
block = cfg.get("hosts", {}).get(host, {})
if not block or block.get("enabled") is False:
print(f" {label}Honcho is already disabled.\n")
return
block["enabled"] = False
_write_config(cfg)
print(f" {label}Honcho disabled.")
print(f" Saved to {_config_path()}\n")
def cmd_sync(args) -> None:
"""Sync Honcho config to all existing profiles.
Scans all Hermes profiles and creates host blocks for any that don't
have one yet. Inherits settings from the default host block.
"""
try:
from hermes_cli.profiles import list_profiles
profiles = list_profiles()
except Exception as e:
print(f" Could not list profiles: {e}\n")
return
cfg = _read_config()
if not cfg:
print(" No Honcho config found. Run 'hermes honcho setup' first.\n")
return
hosts = cfg.get("hosts", {})
default_block = hosts.get(HOST, {})
has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
if not default_block and not has_key:
print(" Honcho not configured on default profile. Run 'hermes honcho setup' first.\n")
return
created = 0
skipped = 0
for p in profiles:
if p.name == "default":
continue
if clone_honcho_for_profile(p.name):
print(f" + {p.name} -> hermes.{p.name}")
created += 1
else:
skipped += 1
if created:
print(f"\n {created} profile(s) synced.")
else:
print(" All profiles already have Honcho config.")
if skipped:
print(f" {skipped} profile(s) already configured (skipped).")
print()
def sync_honcho_profiles_quiet() -> int:
"""Sync Honcho host blocks for all profiles. Returns count of newly created blocks.
Called from `hermes update` -- no output, no exceptions.
"""
try:
from hermes_cli.profiles import list_profiles
profiles = list_profiles()
except Exception:
return 0
cfg = _read_config()
if not cfg:
return 0
default_block = cfg.get("hosts", {}).get(HOST, {})
has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
if not default_block and not has_key:
return 0
created = 0
for p in profiles:
if p.name == "default":
continue
if clone_honcho_for_profile(p.name):
created += 1
return created
_profile_override: str | None = None
def _host_key() -> str:
"""Return the active Honcho host key, derived from the current Hermes profile."""
if _profile_override:
if _profile_override in ("default", "custom"):
return HOST
return f"{HOST}.{_profile_override}"
return resolve_active_host()
def _config_path() -> Path:
@@ -52,7 +274,7 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:
def _resolve_api_key(cfg: dict) -> str:
"""Resolve API key with host -> root -> env fallback."""
host_key = ((cfg.get("hosts") or {}).get(HOST) or {}).get("apiKey")
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
@@ -118,96 +340,140 @@ def cmd_setup(args) -> None:
if not _ensure_sdk_installed():
return
# All writes go to hosts.hermes — root keys are managed by the user
# or the honcho CLI only.
hosts = cfg.setdefault("hosts", {})
hermes_host = hosts.setdefault(HOST, {})
hermes_host = hosts.setdefault(_host_key(), {})
# API key — shared credential, lives at root so all hosts can read it
current_key = cfg.get("apiKey", "")
masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
print(f" Current API key: {masked}")
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
if new_key:
cfg["apiKey"] = new_key
# --- 1. Cloud or local? ---
print(" Deployment:")
print(" cloud -- Honcho cloud (api.honcho.dev)")
print(" local -- self-hosted Honcho server")
current_deploy = "local" if any(
h in (cfg.get("baseUrl") or cfg.get("base_url") or "")
for h in ("localhost", "127.0.0.1", "::1")
) else "cloud"
deploy = _prompt("Cloud or local?", default=current_deploy)
is_local = deploy.lower() in ("local", "l")
effective_key = cfg.get("apiKey", "")
if not effective_key:
print("\n No API key configured. Get your API key at https://app.honcho.dev")
print(" Run 'hermes honcho setup' again once you have a key.\n")
return
# Clean up legacy snake_case key
cfg.pop("base_url", None)
# Peer name
if is_local:
# --- Local: ask for base URL, skip or clear API key ---
current_url = cfg.get("baseUrl") or ""
new_url = _prompt("Base URL", default=current_url or "http://localhost:8000")
if new_url:
cfg["baseUrl"] = new_url
# For local no-auth, the SDK must not send an API key.
# We keep the key in config (for cloud switching later) but
# the client should skip auth when baseUrl is local.
current_key = cfg.get("apiKey", "")
if current_key:
print(f"\n API key present in config (kept for cloud/hybrid use).")
print(" Local connections will skip auth automatically.")
else:
print("\n No API key set. Local no-auth ready.")
else:
# --- Cloud: set default base URL, require API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default
current_key = cfg.get("apiKey", "")
masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
print(f"\n Current API key: {masked}")
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
if new_key:
cfg["apiKey"] = new_key
if not cfg.get("apiKey"):
print("\n No API key configured. Get yours at https://app.honcho.dev")
print(" Run 'hermes honcho setup' again once you have a key.\n")
return
# --- 3. Identity ---
current_peer = hermes_host.get("peerName") or cfg.get("peerName", "")
new_peer = _prompt("Your name (user peer)", default=current_peer or os.getenv("USER", "user"))
if new_peer:
hermes_host["peerName"] = new_peer
current_ai = hermes_host.get("aiPeer") or cfg.get("aiPeer", "hermes")
new_ai = _prompt("AI peer name", default=current_ai)
if new_ai:
hermes_host["aiPeer"] = new_ai
current_workspace = hermes_host.get("workspace") or cfg.get("workspace", "hermes")
new_workspace = _prompt("Workspace ID", default=current_workspace)
if new_workspace:
hermes_host["workspace"] = new_workspace
hermes_host.setdefault("aiPeer", HOST)
# Memory mode
current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid")
print("\n Memory mode options:")
print(" hybrid — write to both Honcho and local MEMORY.md (default)")
print(" honcho — Honcho only, skip MEMORY.md writes")
new_mode = _prompt("Memory mode", default=current_mode)
if new_mode in ("hybrid", "honcho"):
hermes_host["memoryMode"] = new_mode
# --- 4. Observation mode ---
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")
print("\n Observation mode:")
print(" directional -- all observations on, each AI peer builds its own view (default)")
print(" unified -- shared pool, user observes self, AI observes others only")
new_obs = _prompt("Observation mode", default=current_obs)
if new_obs in ("unified", "directional"):
hermes_host["observationMode"] = new_obs
else:
hermes_host["memoryMode"] = "hybrid"
hermes_host["observationMode"] = "directional"
# Write frequency
# --- 5. Write frequency ---
current_wf = str(hermes_host.get("writeFrequency") or cfg.get("writeFrequency", "async"))
print("\n Write frequency options:")
print(" async background thread, no token cost (recommended)")
print(" turn sync write after every turn")
print(" session batch write at session end only")
print(" N write every N turns (e.g. 5)")
print("\n Write frequency:")
print(" async -- background thread, no token cost (recommended)")
print(" turn -- sync write after every turn")
print(" session -- batch write at session end only")
print(" N -- write every N turns (e.g. 5)")
new_wf = _prompt("Write frequency", default=current_wf)
try:
hermes_host["writeFrequency"] = int(new_wf)
except (ValueError, TypeError):
hermes_host["writeFrequency"] = new_wf if new_wf in ("async", "turn", "session") else "async"
# Recall mode
# --- 6. Recall mode ---
_raw_recall = hermes_host.get("recallMode") or cfg.get("recallMode", "hybrid")
current_recall = "hybrid" if _raw_recall not in ("hybrid", "context", "tools") else _raw_recall
print("\n Recall mode options:")
print(" hybrid auto-injected context + Honcho tools available (default)")
print(" context auto-injected context only, Honcho tools hidden")
print(" tools Honcho tools only, no auto-injected context")
print("\n Recall mode:")
print(" hybrid -- auto-injected context + Honcho tools available (default)")
print(" context -- auto-injected context only, Honcho tools hidden")
print(" tools -- Honcho tools only, no auto-injected context")
new_recall = _prompt("Recall mode", default=current_recall)
if new_recall in ("hybrid", "context", "tools"):
hermes_host["recallMode"] = new_recall
# Session strategy
# --- 7. Session strategy ---
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory")
print("\n Session strategy options:")
print(" per-directory one session per working directory (default)")
print(" per-session new Honcho session each run, named by Hermes session ID")
print(" per-repo one session per git repository (uses repo root name)")
print(" global single session across all directories")
print("\n Session strategy:")
print(" per-directory -- one session per working directory (default)")
print(" per-session -- new Honcho session each run")
print(" per-repo -- one session per git repository")
print(" global -- single session across all directories")
new_strat = _prompt("Session strategy", default=current_strat)
if new_strat in ("per-session", "per-repo", "per-directory", "global"):
hermes_host["sessionStrategy"] = new_strat
hermes_host.setdefault("enabled", True)
hermes_host["enabled"] = True
hermes_host.setdefault("saveMessages", True)
_write_config(cfg)
print(f"\n Config written to {write_path}")
# Test connection
# --- Auto-enable Honcho as memory provider in config.yaml ---
try:
from hermes_cli.config import load_config, save_config
hermes_config = load_config()
hermes_config.setdefault("memory", {})["provider"] = "honcho"
save_config(hermes_config)
print(" Memory provider set to 'honcho' in config.yaml")
except Exception as e:
print(f" Could not auto-enable in config.yaml: {e}")
print(" Run: hermes config set memory.provider honcho")
# --- Test connection ---
print(" Testing connection... ", end="", flush=True)
try:
from honcho_integration.client import HonchoClientConfig, get_honcho_client, reset_honcho_client
from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client, reset_honcho_client
reset_honcho_client()
hcfg = HonchoClientConfig.from_global_config()
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
get_honcho_client(hcfg)
print("OK")
except Exception as e:
@@ -217,28 +483,72 @@ def cmd_setup(args) -> None:
print("\n Honcho is ready.")
print(f" Session: {hcfg.resolve_session_name()}")
print(f" Workspace: {hcfg.workspace_id}")
print(f" Peer: {hcfg.peer_name}")
_mode_str = hcfg.memory_mode
if hcfg.peer_memory_modes:
overrides = ", ".join(f"{k}={v}" for k, v in hcfg.peer_memory_modes.items())
_mode_str = f"{hcfg.memory_mode} (peers: {overrides})"
print(f" Mode: {_mode_str}")
print(f" User: {hcfg.peer_name}")
print(f" AI peer: {hcfg.ai_peer}")
print(f" Observe: {hcfg.observation_mode}")
print(f" Frequency: {hcfg.write_frequency}")
print(f" Recall: {hcfg.recall_mode}")
print(f" Sessions: {hcfg.session_strategy}")
print("\n Honcho tools available in chat:")
print(" honcho_context ask Honcho a question about you (LLM-synthesized)")
print(" honcho_search semantic search over your history (no LLM)")
print(" honcho_profile — your peer card, key facts (no LLM)")
print(" honcho_conclude persist a user fact to Honcho memory (no LLM)")
print(" honcho_context -- ask Honcho about the user (LLM-synthesized)")
print(" honcho_search -- semantic search over history (no LLM)")
print(" honcho_profile -- peer card, key facts (no LLM)")
print(" honcho_conclude -- persist a user fact to memory (no LLM)")
print("\n Other commands:")
print(" hermes honcho status show full config")
print(" hermes honcho mode — show or change memory mode")
print(" hermes honcho tokens — show or set token budgets")
print(" hermes honcho identity — seed or show AI peer identity")
print(" hermes honcho map <name> map this directory to a session name\n")
print(" hermes honcho status -- show full config")
print(" hermes honcho mode -- change recall/observation mode")
print(" hermes honcho tokens -- tune context and dialectic budgets")
print(" hermes honcho peer -- update peer names")
print(" hermes honcho map <name> -- map this directory to a session name\n")
def _active_profile_name() -> str:
"""Return the active Hermes profile name (respects --target-profile override)."""
if _profile_override:
return _profile_override
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name()
except Exception:
return "default"
def _all_profile_host_configs() -> list[tuple[str, str, dict]]:
"""Return (profile_name, host_key, host_block) for every known profile.
Reads honcho.json once and maps each profile to its host block.
"""
try:
from hermes_cli.profiles import list_profiles
profiles = list_profiles()
except Exception:
return [(_active_profile_name(), _host_key(), {})]
cfg = _read_config()
hosts = cfg.get("hosts", {})
results = []
# Default profile
default_block = hosts.get(HOST, {})
results.append(("default", HOST, default_block))
for p in profiles:
if p.name == "default":
continue
h = f"{HOST}.{p.name}"
results.append((p.name, h, hosts.get(h, {})))
return results
def cmd_status(args) -> None:
"""Show current Honcho config and connection status."""
show_all = getattr(args, "all", False)
if show_all:
_cmd_status_all()
return
try:
import honcho # noqa: F401
except ImportError:
@@ -256,8 +566,8 @@ def cmd_status(args) -> None:
return
try:
from honcho_integration.client import HonchoClientConfig, get_honcho_client
hcfg = HonchoClientConfig.from_global_config()
from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
except Exception as e:
print(f" Config error: {e}\n")
return
@@ -265,11 +575,16 @@ def cmd_status(args) -> None:
api_key = hcfg.api_key or ""
masked = f"...{api_key[-8:]}" if len(api_key) > 8 else ("set" if api_key else "not set")
print("\nHoncho status\n" + "" * 40)
profile = _active_profile_name()
profile_label = f" [{hcfg.host}]" if profile != "default" else ""
print(f"\nHoncho status{profile_label}\n" + "" * 40)
if profile != "default":
print(f" Profile: {profile}")
print(f" Host: {hcfg.host}")
print(f" Enabled: {hcfg.enabled}")
print(f" API key: {masked}")
print(f" Workspace: {hcfg.workspace_id}")
print(f" Host: {hcfg.host}")
print(f" Config path: {active_path}")
if write_path != active_path:
print(f" Write path: {write_path} (instance-local)")
@@ -277,18 +592,15 @@ def cmd_status(args) -> None:
print(f" User peer: {hcfg.peer_name or 'not set'}")
print(f" Session key: {hcfg.resolve_session_name()}")
print(f" Recall mode: {hcfg.recall_mode}")
print(f" Memory mode: {hcfg.memory_mode}")
if hcfg.peer_memory_modes:
print(" Per-peer modes:")
for peer, mode in hcfg.peer_memory_modes.items():
print(f" {peer}: {mode}")
print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})")
print(f" Write freq: {hcfg.write_frequency}")
if hcfg.enabled and (hcfg.api_key or hcfg.base_url):
print("\n Connection... ", end="", flush=True)
try:
get_honcho_client(hcfg)
print("OK\n")
client = get_honcho_client(hcfg)
print("OK")
_show_peer_cards(hcfg, client)
except Exception as e:
print(f"FAILED ({e})\n")
else:
@@ -296,6 +608,88 @@ def cmd_status(args) -> None:
print(f"\n Not connected ({reason})\n")
def _show_peer_cards(hcfg, client) -> None:
"""Fetch and display peer cards for the active profile.
Uses get_or_create to ensure the session exists with peers configured.
This is idempotent -- if the session already exists on the server it's
just retrieved, not duplicated.
"""
try:
from plugins.memory.honcho.session import HonchoSessionManager
mgr = HonchoSessionManager(honcho=client, config=hcfg)
session_key = hcfg.resolve_session_name()
mgr.get_or_create(session_key)
# User peer card
card = mgr.get_peer_card(session_key)
if card:
print(f"\n User peer card ({len(card)} facts):")
for fact in card[:10]:
print(f" - {fact}")
if len(card) > 10:
print(f" ... and {len(card) - 10} more")
# AI peer representation
ai_rep = mgr.get_ai_representation(session_key)
ai_text = ai_rep.get("representation", "")
if ai_text:
# Truncate to first 200 chars
display = ai_text[:200] + ("..." if len(ai_text) > 200 else "")
print(f"\n AI peer representation:")
print(f" {display}")
if not card and not ai_text:
print("\n No peer data yet (accumulates after first conversation)")
print()
except Exception as e:
print(f"\n Peer data unavailable: {e}\n")
def _cmd_status_all() -> None:
"""Show Honcho config overview across all profiles."""
rows = _all_profile_host_configs()
cfg = _read_config()
active = _active_profile_name()
print(f"\nHoncho profiles ({len(rows)})\n" + "" * 55)
print(f" {'Profile':<14} {'Host':<22} {'Enabled':<9} {'Recall':<9} {'Write'}")
print(f" {'' * 14} {'' * 22} {'' * 9} {'' * 9} {'' * 9}")
for name, host, block in rows:
enabled = block.get("enabled", cfg.get("enabled"))
if enabled is None:
has_creds = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY"))
enabled = has_creds if block else False
enabled_str = "yes" if enabled else "no"
recall = block.get("recallMode") or cfg.get("recallMode", "hybrid")
write = block.get("writeFrequency") or cfg.get("writeFrequency", "async")
marker = " *" if name == active else ""
print(f" {name + marker:<14} {host:<22} {enabled_str:<9} {recall:<9} {write}")
print(f"\n * active profile\n")
def cmd_peers(args) -> None:
"""Show peer identities across all profiles."""
rows = _all_profile_host_configs()
cfg = _read_config()
print(f"\nHoncho peer identities ({len(rows)} profiles)\n" + "" * 50)
print(f" {'Profile':<14} {'User peer':<16} {'AI peer'}")
print(f" {'' * 14} {'' * 16} {'' * 18}")
for name, host, block in rows:
user = block.get("peerName") or cfg.get("peerName") or "(not set)"
ai = block.get("aiPeer") or cfg.get("aiPeer") or host
print(f" {name:<14} {user:<16} {ai}")
print()
def cmd_sessions(args) -> None:
"""List known directory → session name mappings."""
cfg = _read_config()
@@ -354,9 +748,9 @@ def cmd_peer(args) -> None:
if user_name is None and ai_name is None and reasoning is None:
# Show current values
hosts = cfg.get("hosts", {})
hermes = hosts.get(HOST, {})
hermes = hosts.get(_host_key(), {})
user = hermes.get('peerName') or cfg.get('peerName') or '(not set)'
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or HOST
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or _host_key()
lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
print("\nHoncho peers\n" + "" * 40)
@@ -370,23 +764,26 @@ def cmd_peer(args) -> None:
print(f" Dialectic cap: {max_chars} chars\n")
return
host = _host_key()
label = f"[{host}] " if host != "hermes" else ""
if user_name is not None:
cfg.setdefault("hosts", {}).setdefault(HOST, {})["peerName"] = user_name.strip()
cfg.setdefault("hosts", {}).setdefault(host, {})["peerName"] = user_name.strip()
changed = True
print(f" User peer {user_name.strip()}")
print(f" {label}User peer -> {user_name.strip()}")
if ai_name is not None:
cfg.setdefault("hosts", {}).setdefault(HOST, {})["aiPeer"] = ai_name.strip()
cfg.setdefault("hosts", {}).setdefault(host, {})["aiPeer"] = ai_name.strip()
changed = True
print(f" AI peer {ai_name.strip()}")
print(f" {label}AI peer -> {ai_name.strip()}")
if reasoning is not None:
if reasoning not in REASONING_LEVELS:
print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
return
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticReasoningLevel"] = reasoning
cfg.setdefault("hosts", {}).setdefault(host, {})["dialecticReasoningLevel"] = reasoning
changed = True
print(f" Dialectic reasoning level {reasoning}")
print(f" {label}Dialectic reasoning level -> {reasoning}")
if changed:
_write_config(cfg)
@@ -394,41 +791,44 @@ def cmd_peer(args) -> None:
def cmd_mode(args) -> None:
"""Show or set the memory mode."""
"""Show or set the recall mode."""
MODES = {
"hybrid": "write to both Honcho and local MEMORY.md (default)",
"honcho": "Honcho only — MEMORY.md writes disabled",
"hybrid": "auto-injected context + Honcho tools available (default)",
"context": "auto-injected context only, Honcho tools hidden",
"tools": "Honcho tools only, no auto-injected context",
}
cfg = _read_config()
mode_arg = getattr(args, "mode", None)
if mode_arg is None:
current = (
(cfg.get("hosts") or {}).get(HOST, {}).get("memoryMode")
or cfg.get("memoryMode")
(cfg.get("hosts") or {}).get(_host_key(), {}).get("recallMode")
or cfg.get("recallMode")
or "hybrid"
)
print("\nHoncho memory mode\n" + "" * 40)
print("\nHoncho recall mode\n" + "" * 40)
for m, desc in MODES.items():
marker = " " if m == current else ""
print(f" {m:<8} {desc}{marker}")
print("\n Set with: hermes honcho mode [hybrid|honcho]\n")
marker = " <-" if m == current else ""
print(f" {m:<10} {desc}{marker}")
print(f"\n Set with: hermes honcho mode [hybrid|context|tools]\n")
return
if mode_arg not in MODES:
print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n")
return
cfg.setdefault("hosts", {}).setdefault(HOST, {})["memoryMode"] = mode_arg
host = _host_key()
label = f"[{host}] " if host != "hermes" else ""
cfg.setdefault("hosts", {}).setdefault(host, {})["recallMode"] = mode_arg
_write_config(cfg)
print(f" Memory mode {mode_arg} ({MODES[mode_arg]})\n")
print(f" {label}Recall mode -> {mode_arg} ({MODES[mode_arg]})\n")
def cmd_tokens(args) -> None:
"""Show or set token budget settings."""
cfg = _read_config()
hosts = cfg.get("hosts", {})
hermes = hosts.get(HOST, {})
hermes = hosts.get(_host_key(), {})
context = getattr(args, "context", None)
dialectic = getattr(args, "dialectic", None)
@@ -451,14 +851,16 @@ def cmd_tokens(args) -> None:
print("\n Set with: hermes honcho tokens [--context N] [--dialectic N]\n")
return
host = _host_key()
label = f"[{host}] " if host != "hermes" else ""
changed = False
if context is not None:
cfg.setdefault("hosts", {}).setdefault(HOST, {})["contextTokens"] = context
print(f" context tokens {context}")
cfg.setdefault("hosts", {}).setdefault(host, {})["contextTokens"] = context
print(f" {label}context tokens -> {context}")
changed = True
if dialectic is not None:
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticMaxChars"] = dialectic
print(f" dialectic cap {dialectic} chars")
cfg.setdefault("hosts", {}).setdefault(host, {})["dialecticMaxChars"] = dialectic
print(f" {label}dialectic cap -> {dialectic} chars")
changed = True
if changed:
@@ -477,9 +879,9 @@ def cmd_identity(args) -> None:
show = getattr(args, "show", False)
try:
from honcho_integration.client import HonchoClientConfig, get_honcho_client
from honcho_integration.session import HonchoSessionManager
hcfg = HonchoClientConfig.from_global_config()
from plugins.memory.honcho.client import HonchoClientConfig, get_honcho_client
from plugins.memory.honcho.session import HonchoSessionManager
hcfg = HonchoClientConfig.from_global_config(host=_host_key())
client = get_honcho_client(hcfg)
mgr = HonchoSessionManager(honcho=client, config=hcfg)
session_key = hcfg.resolve_session_name()
@@ -642,12 +1044,12 @@ def cmd_migrate(args) -> None:
answer = _prompt(" Upload user memory files to Honcho now?", default="y")
if answer.lower() in ("y", "yes"):
try:
from honcho_integration.client import (
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
reset_honcho_client,
)
from honcho_integration.session import HonchoSessionManager
from plugins.memory.honcho.session import HonchoSessionManager
reset_honcho_client()
hcfg = HonchoClientConfig.from_global_config()
@@ -692,12 +1094,12 @@ def cmd_migrate(args) -> None:
answer = _prompt(" Seed AI identity from all detected files now?", default="y")
if answer.lower() in ("y", "yes"):
try:
from honcho_integration.client import (
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
reset_honcho_client,
)
from honcho_integration.session import HonchoSessionManager
from plugins.memory.honcho.session import HonchoSessionManager
reset_honcho_client()
hcfg = HonchoClientConfig.from_global_config()
@@ -770,11 +1172,23 @@ def cmd_migrate(args) -> None:
def honcho_command(args) -> None:
"""Route honcho subcommands."""
global _profile_override
_profile_override = getattr(args, "target_profile", None)
sub = getattr(args, "honcho_command", None)
if sub == "setup" or sub is None:
cmd_setup(args)
if sub == "setup":
# Redirect to memory setup — honcho setup goes through the unified path
print("\n Honcho is configured via the memory provider system.")
print(" Running 'hermes memory setup'...\n")
from hermes_cli.memory_setup import cmd_setup_provider
cmd_setup_provider("honcho")
return
elif sub is None:
cmd_status(args)
elif sub == "status":
cmd_status(args)
elif sub == "peers":
cmd_peers(args)
elif sub == "sessions":
cmd_sessions(args)
elif sub == "map":
@@ -789,6 +1203,104 @@ def honcho_command(args) -> None:
cmd_identity(args)
elif sub == "migrate":
cmd_migrate(args)
elif sub == "enable":
cmd_enable(args)
elif sub == "disable":
cmd_disable(args)
elif sub == "sync":
cmd_sync(args)
else:
print(f" Unknown honcho command: {sub}")
print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate\n")
print(" Available: status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n")
def register_cli(subparser) -> None:
"""Build the ``hermes honcho`` argparse subcommand tree.
Called by the plugin CLI registration system during argparse setup.
The *subparser* is the parser for ``hermes honcho``.
"""
import argparse
subparser.add_argument(
"--target-profile", metavar="NAME", dest="target_profile",
help="Target a specific profile's Honcho config without switching",
)
subs = subparser.add_subparsers(dest="honcho_command")
subs.add_parser(
"setup",
help="Initial Honcho setup (redirects to hermes memory setup)",
)
status_parser = subs.add_parser(
"status", help="Show current Honcho config and connection status",
)
status_parser.add_argument(
"--all", action="store_true", help="Show config overview across all profiles",
)
subs.add_parser("peers", help="Show peer identities across all profiles")
subs.add_parser("sessions", help="List known Honcho session mappings")
map_parser = subs.add_parser(
"map", help="Map current directory to a Honcho session name (no arg = list mappings)",
)
map_parser.add_argument(
"session_name", nargs="?", default=None,
help="Session name to associate with this directory. Omit to list current mappings.",
)
peer_parser = subs.add_parser(
"peer", help="Show or update peer names and dialectic reasoning level",
)
peer_parser.add_argument("--user", metavar="NAME", help="Set user peer name")
peer_parser.add_argument("--ai", metavar="NAME", help="Set AI peer name")
peer_parser.add_argument(
"--reasoning", metavar="LEVEL",
choices=("minimal", "low", "medium", "high", "max"),
help="Set default dialectic reasoning level (minimal/low/medium/high/max)",
)
mode_parser = subs.add_parser(
"mode", help="Show or set recall mode (hybrid/context/tools)",
)
mode_parser.add_argument(
"mode", nargs="?", metavar="MODE",
choices=("hybrid", "context", "tools"),
help="Recall mode to set (hybrid/context/tools). Omit to show current.",
)
tokens_parser = subs.add_parser(
"tokens", help="Show or set token budget for context and dialectic",
)
tokens_parser.add_argument(
"--context", type=int, metavar="N",
help="Max tokens Honcho returns from session.context() per turn",
)
tokens_parser.add_argument(
"--dialectic", type=int, metavar="N",
help="Max chars of dialectic result to inject into system prompt",
)
identity_parser = subs.add_parser(
"identity", help="Seed or show the AI peer's Honcho identity representation",
)
identity_parser.add_argument(
"file", nargs="?", default=None,
help="Path to file to seed from (e.g. SOUL.md). Omit to show usage.",
)
identity_parser.add_argument(
"--show", action="store_true",
help="Show current AI peer representation from Honcho",
)
subs.add_parser(
"migrate",
help="Step-by-step migration guide from openclaw-honcho to Hermes Honcho",
)
subs.add_parser("enable", help="Enable Honcho for the active profile")
subs.add_parser("disable", help="Disable Honcho for the active profile")
subs.add_parser("sync", help="Sync Honcho config to all existing profiles")
subparser.set_defaults(func=honcho_command)
@@ -31,16 +31,47 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
HOST = "hermes"
def resolve_active_host() -> str:
"""Derive the Honcho host key from the active Hermes profile.
Resolution order:
1. HERMES_HONCHO_HOST env var (explicit override)
2. Active profile name via profiles system -> ``hermes.<profile>``
3. Fallback: ``"hermes"`` (default profile)
"""
explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip()
if explicit:
return explicit
try:
from hermes_cli.profiles import get_active_profile_name
profile = get_active_profile_name()
if profile and profile not in ("default", "custom"):
return f"{HOST}.{profile}"
except Exception:
pass
return HOST
def resolve_config_path() -> Path:
"""Return the active Honcho config path.
Checks $HERMES_HOME/honcho.json first (instance-local), then falls back
to ~/.honcho/config.json (global). Returns the global path if neither
exists (for first-time setup writes).
Resolution order:
1. $HERMES_HOME/honcho.json (profile-local, if it exists)
2. ~/.hermes/honcho.json (default profile shared host blocks live here)
3. ~/.honcho/config.json (global, cross-app interop)
Returns the global path if none exist (for first-time setup writes).
"""
local_path = get_hermes_home() / "honcho.json"
if local_path.exists():
return local_path
# Default profile's config — host blocks accumulate here via setup/clone
default_path = Path.home() / ".hermes" / "honcho.json"
if default_path != local_path and default_path.exists():
return default_path
return GLOBAL_CONFIG_PATH
@@ -54,28 +85,68 @@ def _normalize_recall_mode(val: str) -> str:
return val if val in _VALID_RECALL_MODES else "hybrid"
def _resolve_memory_mode(
global_val: str | dict,
host_val: str | dict | None,
def _resolve_bool(host_val, root_val, *, default: bool) -> bool:
"""Resolve a bool config field: host wins, then root, then default."""
if host_val is not None:
return bool(host_val)
if root_val is not None:
return bool(root_val)
return default
_VALID_OBSERVATION_MODES = {"unified", "directional"}
_OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"}
def _normalize_observation_mode(val: str) -> str:
"""Normalize observation mode values."""
val = _OBSERVATION_MODE_ALIASES.get(val, val)
return val if val in _VALID_OBSERVATION_MODES else "directional"
# Observation presets — granular booleans derived from legacy string mode.
# Explicit per-peer config always wins over presets.
_OBSERVATION_PRESETS = {
"directional": {
"user_observe_me": True, "user_observe_others": True,
"ai_observe_me": True, "ai_observe_others": True,
},
"unified": {
"user_observe_me": True, "user_observe_others": False,
"ai_observe_me": False, "ai_observe_others": True,
},
}
def _resolve_observation(
mode: str,
observation_obj: dict | None,
) -> dict:
"""Parse memoryMode (string or object) into memory_mode + peer_memory_modes.
"""Resolve per-peer observation booleans.
Resolution order: host-level wins over global.
String form: applies as the default for all peers.
Object form: { "default": "hybrid", "hermes": "honcho", ... }
"default" key sets the fallback; other keys are per-peer overrides.
Config forms:
String shorthand: ``"observationMode": "directional"``
Granular object: ``"observation": {"user": {"observeMe": true, "observeOthers": true},
"ai": {"observeMe": true, "observeOthers": false}}``
Granular fields override preset defaults.
"""
# Pick the winning value (host beats global)
val = host_val if host_val is not None else global_val
preset = _OBSERVATION_PRESETS.get(mode, _OBSERVATION_PRESETS["directional"])
if not observation_obj or not isinstance(observation_obj, dict):
return dict(preset)
user_block = observation_obj.get("user") or {}
ai_block = observation_obj.get("ai") or {}
return {
"user_observe_me": user_block.get("observeMe", preset["user_observe_me"]),
"user_observe_others": user_block.get("observeOthers", preset["user_observe_others"]),
"ai_observe_me": ai_block.get("observeMe", preset["ai_observe_me"]),
"ai_observe_others": ai_block.get("observeOthers", preset["ai_observe_others"]),
}
if isinstance(val, dict):
default = val.get("default", "hybrid")
overrides = {k: v for k, v in val.items() if k != "default"}
else:
default = str(val) if val else "hybrid"
overrides = {}
return {"memory_mode": default, "peer_memory_modes": overrides}
@dataclass
@@ -91,22 +162,9 @@ class HonchoClientConfig:
# Identity
peer_name: str | None = None
ai_peer: str = "hermes"
linked_hosts: list[str] = field(default_factory=list)
# Toggles
enabled: bool = False
save_messages: bool = True
# memoryMode: default for all peers. "hybrid" / "honcho"
memory_mode: str = "hybrid"
# Per-peer overrides — any named Honcho peer. Override memory_mode when set.
# Config object form: "memoryMode": { "default": "hybrid", "hermes": "honcho" }
peer_memory_modes: dict[str, str] = field(default_factory=dict)
def peer_memory_mode(self, peer_name: str) -> str:
"""Return the effective memory mode for a named peer.
Resolution: per-peer override global memory_mode default.
"""
return self.peer_memory_modes.get(peer_name, self.memory_mode)
# Write frequency: "async" (background thread), "turn" (sync per turn),
# "session" (flush on session end), or int (every N turns)
write_frequency: str | int = "async"
@@ -114,15 +172,32 @@ class HonchoClientConfig:
context_tokens: int | None = None
# Dialectic (peer.chat) settings
# reasoning_level: "minimal" | "low" | "medium" | "high" | "max"
# Used as the default; prefetch_dialectic may bump it dynamically.
dialectic_reasoning_level: str = "low"
# dynamic: auto-bump reasoning level based on query length
# true — low->medium (120+ chars), low->high (400+ chars), capped at "high"
# false — always use dialecticReasoningLevel as-is
dialectic_dynamic: bool = True
# Max chars of dialectic result to inject into Hermes system prompt
dialectic_max_chars: int = 600
# Honcho API limits — configurable for self-hosted instances
# Max chars per message sent via add_messages() (Honcho cloud: 25000)
message_max_chars: int = 25000
# Max chars for dialectic query input to peer.chat() (Honcho cloud: 10000)
dialectic_max_input_chars: int = 10000
# Recall mode: how memory retrieval works when Honcho is active.
# "hybrid" — auto-injected context + Honcho tools available (model decides)
# "context" — auto-injected context only, Honcho tools removed
# "tools" — Honcho tools only, no auto-injected context
recall_mode: str = "hybrid"
# Observation mode: legacy string shorthand ("directional" or "unified").
# Kept for backward compat; granular per-peer booleans below are preferred.
observation_mode: str = "directional"
# Per-peer observation booleans — maps 1:1 to Honcho's SessionPeerConfig.
# Resolved from "observation" object in config, falling back to observation_mode preset.
user_observe_me: bool = True
user_observe_others: bool = True
ai_observe_me: bool = True
ai_observe_others: bool = True
# Session resolution
session_strategy: str = "per-directory"
session_peer_prefix: bool = False
@@ -135,40 +210,49 @@ class HonchoClientConfig:
explicitly_configured: bool = False
@classmethod
def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig:
def from_env(
cls,
workspace_id: str = "hermes",
host: str | None = None,
) -> HonchoClientConfig:
"""Create config from environment variables (fallback)."""
resolved_host = host or resolve_active_host()
api_key = os.environ.get("HONCHO_API_KEY")
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
return cls(
host=resolved_host,
workspace_id=workspace_id,
api_key=api_key,
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
base_url=base_url,
ai_peer=resolved_host,
enabled=bool(api_key or base_url),
)
@classmethod
def from_global_config(
cls,
host: str = HOST,
host: str | None = None,
config_path: Path | None = None,
) -> HonchoClientConfig:
"""Create config from the resolved Honcho config path.
Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
When host is None, derives it from the active Hermes profile.
"""
resolved_host = host or resolve_active_host()
path = config_path or resolve_config_path()
if not path.exists():
logger.debug("No global Honcho config at %s, falling back to env", path)
return cls.from_env()
return cls.from_env(host=resolved_host)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read %s: %s, falling back to env", path, e)
return cls.from_env()
return cls.from_env(host=resolved_host)
host_block = (raw.get("hosts") or {}).get(host, {})
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
# A hosts.hermes block or explicit enabled flag means the user
# intentionally configured Honcho for this host.
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
@@ -177,15 +261,13 @@ class HonchoClientConfig:
workspace = (
host_block.get("workspace")
or raw.get("workspace")
or host
or resolved_host
)
ai_peer = (
host_block.get("aiPeer")
or raw.get("aiPeer")
or host
or resolved_host
)
linked_hosts = host_block.get("linkedHosts", [])
api_key = (
host_block.get("apiKey")
or raw.get("apiKey")
@@ -199,6 +281,7 @@ class HonchoClientConfig:
base_url = (
raw.get("baseUrl")
or raw.get("base_url")
or os.environ.get("HONCHO_BASE_URL", "").strip()
or None
)
@@ -242,20 +325,15 @@ class HonchoClientConfig:
)
return cls(
host=host,
host=resolved_host,
workspace_id=workspace,
api_key=api_key,
environment=environment,
base_url=base_url,
peer_name=host_block.get("peerName") or raw.get("peerName"),
ai_peer=ai_peer,
linked_hosts=linked_hosts,
enabled=enabled,
save_messages=save_messages,
**_resolve_memory_mode(
raw.get("memoryMode", "hybrid"),
host_block.get("memoryMode"),
),
write_frequency=write_frequency,
context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"),
dialectic_reasoning_level=(
@@ -263,16 +341,49 @@ class HonchoClientConfig:
or raw.get("dialecticReasoningLevel")
or "low"
),
dialectic_dynamic=_resolve_bool(
host_block.get("dialecticDynamic"),
raw.get("dialecticDynamic"),
default=True,
),
dialectic_max_chars=int(
host_block.get("dialecticMaxChars")
or raw.get("dialecticMaxChars")
or 600
),
message_max_chars=int(
host_block.get("messageMaxChars")
or raw.get("messageMaxChars")
or 25000
),
dialectic_max_input_chars=int(
host_block.get("dialecticMaxInputChars")
or raw.get("dialecticMaxInputChars")
or 10000
),
recall_mode=_normalize_recall_mode(
host_block.get("recallMode")
or raw.get("recallMode")
or "hybrid"
),
# Migration guard: existing configs without an explicit
# observationMode keep the old "unified" default so users
# aren't silently switched to full bidirectional observation.
# New installations (no host block, no credentials) get
# "directional" (all observations on) as the new default.
observation_mode=_normalize_observation_mode(
host_block.get("observationMode")
or raw.get("observationMode")
or ("unified" if _explicitly_configured else "directional")
),
**_resolve_observation(
_normalize_observation_mode(
host_block.get("observationMode")
or raw.get("observationMode")
or ("unified" if _explicitly_configured else "directional")
),
host_block.get("observation") or raw.get("observation"),
),
session_strategy=session_strategy,
session_peer_prefix=session_peer_prefix,
sessions=raw.get("sessions", {}),
@@ -353,17 +464,6 @@ class HonchoClientConfig:
# global: single session across all directories
return self.workspace_id
def get_linked_workspaces(self) -> list[str]:
"""Resolve linked host keys to workspace names."""
hosts = self.raw.get("hosts", {})
workspaces = []
for host_key in self.linked_hosts:
block = hosts.get(host_key, {})
ws = block.get("workspace") or host_key
if ws != self.workspace_id:
workspaces.append(ws)
return workspaces
_honcho_client: Honcho | None = None
@@ -419,12 +519,22 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
# Local Honcho instances don't require an API key, but the SDK
# expects a non-empty string. Use a placeholder for local URLs.
# For local: only use config.api_key if the host block explicitly
# sets apiKey (meaning the user wants local auth). Otherwise skip
# the stored key -- it's likely a cloud key that would break local.
_is_local = resolved_base_url and (
"localhost" in resolved_base_url
or "127.0.0.1" in resolved_base_url
or "::1" in resolved_base_url
)
effective_api_key = config.api_key or ("local" if _is_local else None)
if _is_local:
# Check if the host block has its own apiKey (explicit local auth)
_raw = config.raw or {}
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
_host_has_key = bool(_host_block.get("apiKey"))
effective_api_key = config.api_key if _host_has_key else "local"
else:
effective_api_key = config.api_key
kwargs: dict = {
"workspace_id": config.workspace_id,
+7
View File
@@ -0,0 +1,7 @@
name: honcho
version: 1.0.0
description: "Honcho AI-native memory — cross-session user modeling with dialectic Q&A, semantic search, and persistent conclusions."
pip_dependencies:
- honcho-ai
hooks:
- on_session_end

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