forked from NousResearch/hermes-agent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhermes_time.py
More file actions
93 lines (78 loc) · 3.67 KB
/
Copy pathhermes_time.py
File metadata and controls
93 lines (78 loc) · 3.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
"""Timezone-aware clock for Hermes.
``now()`` returns a tz-aware datetime in the user's configured IANA timezone. Resolution order:
``HERMES_TIMEZONE`` env var, then ``timezone`` in ``~/.hermes/config.yaml``, else server-local
time. Invalid timezone values log a warning and fall back — never crash.
"""
import logging
import os
import threading
from datetime import datetime
from typing import Dict, Optional, Tuple
from zoneinfo import ZoneInfo
from hermes_constants import get_config_path
logger = logging.getLogger(__name__)
# Cache keyed by timezone *source* identity. This process can multiplex profiles by switching
# HERMES_HOME, so one unkeyed global would leak the first profile's timezone into later
# profile-scoped work (e.g. the desktop multiplex cron ticker persisting another profile's
# ``next_run_at``). Entries are published atomically under ``_cache_lock`` as one
# ``identity -> (name, ZoneInfo | None)`` value, so racing resolvers can never publish a mixed
# identity/value pair. Call reset_cache() after in-place config changes.
_cache_lock = threading.Lock()
_tz_cache: Dict[Tuple[str, str], Tuple[str, Optional[ZoneInfo]]] = {}
def _timezone_cache_identity() -> Tuple[str, str]:
tz_env = os.getenv("HERMES_TIMEZONE", "").strip()
return ("environment", tz_env) if tz_env else ("config", str(get_config_path()))
def _resolve_timezone_name() -> str:
"""Read the configured IANA timezone string (or ``""``). Does file I/O — callers cache."""
tz_env = os.getenv("HERMES_TIMEZONE", "").strip()
if tz_env:
return tz_env
try:
# Prefer the shared cached raw-config reader (mtime-keyed + libyaml): a direct safe_load of
# a large config.yaml costs ~100 ms and this ran inside the FIRST system prompt build.
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config() or {}
except Exception:
import yaml
config_path = get_config_path()
cfg = (yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}) if config_path.exists() else {}
if cfg:
# Managed scope: an administrator can pin ``timezone`` too (fail-open overlay).
try:
from hermes_cli import managed_scope
cfg = managed_scope.apply_managed_overlay(cfg)
except Exception:
pass
tz_cfg = cfg.get("timezone", "")
if isinstance(tz_cfg, str) and tz_cfg.strip():
return tz_cfg.strip()
except Exception:
pass
return ""
def get_timezone() -> Optional[ZoneInfo]:
"""Return the active profile's configured ZoneInfo, or None (server-local)."""
cache_identity = _timezone_cache_identity()
with _cache_lock:
entry = _tz_cache.get(cache_identity)
if entry is not None:
return entry[1]
# Resolve outside the lock (config file I/O); first writer wins so concurrent resolvers of the
# same identity converge on one ZoneInfo object.
name = _resolve_timezone_name()
tz = None
if name:
try:
tz = ZoneInfo(name)
except Exception as exc:
logger.warning("Invalid timezone '%s': %s. Falling back to server local time.", name, exc)
with _cache_lock:
return _tz_cache.setdefault(cache_identity, (name, tz))[1]
def reset_cache() -> None:
"""Clear the cached timezone so the next call re-resolves it (after config/env changes)."""
with _cache_lock:
_tz_cache.clear()
def now() -> datetime:
"""Current time as a tz-aware datetime: configured zone, else server-local."""
tz = get_timezone()
return datetime.now(tz) if tz is not None else datetime.now().astimezone()