mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* refactor: add shared helper modules for code deduplication New modules: - gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator, strip_markdown, ThreadParticipationTracker, redact_phone - hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers - tools/path_security.py: validate_within_dir, has_traversal_component - utils.py additions: safe_json_loads, read_json_file, read_jsonl, append_jsonl, env_str/lower/int/bool helpers - hermes_constants.py additions: get_config_path, get_skills_dir, get_logs_dir, get_env_path * refactor: migrate gateway adapters to shared helpers - MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost - strip_markdown: bluebubbles, feishu, sms - redact_phone: sms, signal - ThreadParticipationTracker: discord, matrix - _acquire/_release_platform_lock: telegram, discord, slack, whatsapp, signal, weixin Net -316 lines across 19 files. * refactor: migrate CLI modules to shared helpers - tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines) - setup.py: use cli_output print helpers + curses_radiolist (-101 lines) - mcp_config.py: use cli_output prompt (-15 lines) - memory_setup.py: use curses_radiolist (-86 lines) Net -263 lines across 5 files. * refactor: migrate to shared utility helpers - safe_json_loads: agent/display.py (4 sites) - get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py - get_skills_dir: skill_utils.py, prompt_builder.py - Token estimation dedup: skills_tool.py imports from model_metadata - Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files - Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write - Platform dict: new platforms.py, skills_config + tools_config derive from it - Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main * test: update tests for shared helper migrations - test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate() - test_mattermost: use _dedup instead of _seen_posts/_prune_seen - test_signal: import redact_phone from helpers instead of signal - test_discord_connect: _platform_lock_identity instead of _token_lock_identity - test_telegram_conflict: updated lock error message format - test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
104 lines
3.1 KiB
Python
104 lines
3.1 KiB
Python
"""
|
|
Timezone-aware clock for Hermes.
|
|
|
|
Provides a single ``now()`` helper that returns a timezone-aware datetime
|
|
based on the user's configured IANA timezone (e.g. ``Asia/Kolkata``).
|
|
|
|
Resolution order:
|
|
1. ``HERMES_TIMEZONE`` environment variable
|
|
2. ``timezone`` key in ``~/.hermes/config.yaml``
|
|
3. Falls back to the server's local time (``datetime.now().astimezone()``)
|
|
|
|
Invalid timezone values log a warning and fall back safely — Hermes never
|
|
crashes due to a bad timezone string.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from hermes_constants import get_config_path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
except ImportError:
|
|
# Python 3.8 fallback (shouldn't be needed — Hermes requires 3.9+)
|
|
from backports.zoneinfo import ZoneInfo # type: ignore[no-redef]
|
|
|
|
# Cached state — resolved once, reused on every call.
|
|
# Call reset_cache() to force re-resolution (e.g. after config changes).
|
|
_cached_tz: Optional[ZoneInfo] = None
|
|
_cached_tz_name: Optional[str] = None
|
|
_cache_resolved: bool = False
|
|
|
|
|
|
def _resolve_timezone_name() -> str:
|
|
"""Read the configured IANA timezone string (or empty string).
|
|
|
|
This does file I/O when falling through to config.yaml, so callers
|
|
should cache the result rather than calling on every ``now()``.
|
|
"""
|
|
# 1. Environment variable (highest priority — set by Supervisor, etc.)
|
|
tz_env = os.getenv("HERMES_TIMEZONE", "").strip()
|
|
if tz_env:
|
|
return tz_env
|
|
|
|
# 2. config.yaml ``timezone`` key
|
|
try:
|
|
import yaml
|
|
config_path = get_config_path()
|
|
if config_path.exists():
|
|
with open(config_path) as f:
|
|
cfg = yaml.safe_load(f) or {}
|
|
tz_cfg = cfg.get("timezone", "")
|
|
if isinstance(tz_cfg, str) and tz_cfg.strip():
|
|
return tz_cfg.strip()
|
|
except Exception:
|
|
pass
|
|
|
|
return ""
|
|
|
|
|
|
def _get_zoneinfo(name: str) -> Optional[ZoneInfo]:
|
|
"""Validate and return a ZoneInfo, or None if invalid."""
|
|
if not name:
|
|
return None
|
|
try:
|
|
return ZoneInfo(name)
|
|
except (KeyError, Exception) as exc:
|
|
logger.warning(
|
|
"Invalid timezone '%s': %s. Falling back to server local time.",
|
|
name, exc,
|
|
)
|
|
return None
|
|
|
|
|
|
def get_timezone() -> Optional[ZoneInfo]:
|
|
"""Return the user's configured ZoneInfo, or None (meaning server-local).
|
|
|
|
Resolved once and cached. Call ``reset_cache()`` after config changes.
|
|
"""
|
|
global _cached_tz, _cached_tz_name, _cache_resolved
|
|
if not _cache_resolved:
|
|
_cached_tz_name = _resolve_timezone_name()
|
|
_cached_tz = _get_zoneinfo(_cached_tz_name)
|
|
_cache_resolved = True
|
|
return _cached_tz
|
|
|
|
|
|
def now() -> datetime:
|
|
"""
|
|
Return the current time as a timezone-aware datetime.
|
|
|
|
If a valid timezone is configured, returns wall-clock time in that zone.
|
|
Otherwise returns the server's local time (via ``astimezone()``).
|
|
"""
|
|
tz = get_timezone()
|
|
if tz is not None:
|
|
return datetime.now(tz)
|
|
# No timezone configured — use server-local (still tz-aware)
|
|
return datetime.now().astimezone()
|
|
|
|
|