hermes-agent-kit 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-agent-kit
3
+ Version: 0.2.1
4
+ Summary: Production hardening pack for Hermes Agent — per-topic model routing, fallback chains, rate limiting, and cost tracking via gateway hooks
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pyyaml>=6
10
+ Dynamic: license-file
11
+
12
+ # hermes-kit
13
+
14
+ Production hardening pack for [Hermes Agent](https://github.com/NousResearch/hermes-agent).
15
+
16
+ Self-hosted Hermes gateways are powerful but built for single-user setups. Multi-user deployments hit walls: no per-topic model routing, API failures surface as hard errors, one heavy user can burn your API budget with no alert.
17
+
18
+ hermes-kit fills these gaps with production-grade hooks.
19
+
20
+ > ⚠️ **How it works**: hermes-kit monkey-patches Hermes Agent's internal model resolver at runtime. This is intentionally fragile — Hermes Agent updates may break your setup. We're working on an upstream PR to replace the patch with native hook return values. Until then, test after every Hermes upgrade.
21
+
22
+ ## Prerequisites
23
+
24
+ - Python ≥ 3.11
25
+ - [Hermes Agent](https://github.com/NousResearch/hermes-agent) installed
26
+ - A configured gateway (Telegram, Discord, etc.)
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install hermes-agent-kit
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ ```bash
37
+ # Install all hooks in one command
38
+ hermes-kit install router fallback rate-limiter cost-tracker
39
+
40
+ # Verify
41
+ hermes-kit doctor
42
+
43
+ # Start gateway with bridge auto-patched
44
+ hermes-kit gateway run --accept-hooks
45
+
46
+ # If new users get "I don't recognize you":
47
+ GATEWAY_ALLOW_ALL_USERS=true hermes-kit gateway run --accept-hooks
48
+ ```
49
+
50
+ Hooks land in `~/.hermes/hooks/<name>/`. Hermes discovers them on restart.
51
+
52
+ ## Modules
53
+
54
+ ### router — Per-Topic Model Routing
55
+
56
+ Route Telegram topics to different AI models. Finance chat uses Qwen, coding chat uses DeepSeek, everything else falls back to GPT-4o-mini.
57
+
58
+ **Via CLI:**
59
+ ```bash
60
+ hermes-kit router set-default --model opencode-go/gpt-4o-mini
61
+ hermes-kit router add 42 --model opencode-go/deepseek-v4-pro
62
+ hermes-kit router show
63
+ ```
64
+
65
+ **Via YAML** (`~/.hermes/hooks/router/topic_router.yaml`):
66
+ ```yaml
67
+ default:
68
+ model: "opencode-go/gpt-4o-mini"
69
+
70
+ topics:
71
+ "42":
72
+ model: "opencode-go/deepseek-v4-pro"
73
+ ```
74
+
75
+ **Multi-provider** — route specific topics to native providers:
76
+ ```bash
77
+ hermes-kit router add 42 --model gpt-4o --provider openai
78
+ hermes-kit router add 7 --model claude-sonnet-4-20250514 --provider anthropic
79
+ ```
80
+
81
+ Hermes resolves API keys from `~/.hermes/.env` (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). See [providers guide](docs/providers.md) for all supported providers and model IDs.
82
+
83
+ ### fallback — Automatic Fallback Chain
84
+
85
+ Define a chain of models to try when the primary fails.
86
+
87
+ **Via YAML** (`~/.hermes/hooks/fallback/fallback_chain.yaml`):
88
+ ```yaml
89
+ chains:
90
+ global:
91
+ - "opencode-go/deepseek-v4-pro" # primary
92
+ - "opencode-go/claude-sonnet-4" # fallback
93
+ - "opencode-go/gpt-4o-mini" # last resort
94
+ ```
95
+
96
+ After a failure, call `hermes_kit.bridge.retry_with_fallback(session_key)` to advance to the next model.
97
+
98
+ ### rate-limiter — Per-User Rate Limiting
99
+
100
+ Prevent a single user or chat from draining your API budget.
101
+
102
+ **Via YAML** (`~/.hermes/hooks/rate-limiter/rate_limits.yaml`):
103
+ ```yaml
104
+ limits:
105
+ global:
106
+ max_messages_per_window: 100
107
+ window_seconds: 3600
108
+ per_user:
109
+ "123456789":
110
+ max_messages_per_window: 50
111
+ ```
112
+
113
+ > ⚠️ Rate limiter currently tracks usage but does not block messages. Enforcement is planned for an upcoming release.
114
+
115
+ ### cost-tracker — Real-Time Cost Tracking
116
+
117
+ Track token costs per session and alert when thresholds are exceeded.
118
+
119
+ **Via YAML** (`~/.hermes/hooks/cost-tracker/cost_tracker.yaml`):
120
+ ```yaml
121
+ alert_threshold_usd: 1.0
122
+ ```
123
+
124
+ Set to `0` to disable alerts but continue tracking.
125
+
126
+ ## Docs
127
+
128
+ - [Quickstart](docs/quickstart.md) — agent-driven and manual install
129
+ - [Providers](docs/providers.md) — supported AI providers and model lists
130
+ - Manual setup per module:
131
+ - [Router](docs/manual/router.md) — per-topic model routing
132
+ - [Fallback](docs/manual/fallback.md) — automatic retry chains
133
+ - [Rate Limiter](docs/manual/rate-limiter.md) — per-user quotas
134
+ - [Cost Tracker](docs/manual/cost-tracker.md) — budget alerts
135
+ - [Troubleshooting](docs/troubleshooting.md) — common issues
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,23 @@
1
+ hermes_agent_kit-0.2.1.dist-info/licenses/LICENSE,sha256=gbOQIITSiUtvLsO_NrQePHVPJQ3VMzi0IGIs5MWauGc,1062
2
+ hermes_kit/__init__.py,sha256=MWwqX2Yqp-kKZRorkcUHHdbMRCd4eYKKmPhFVrMXHMg,66
3
+ hermes_kit/bridge.py,sha256=i6ZAgNDrEhGj1x9Pmgh3C0wqUbhr4ynNl42A75V5K7U,5252
4
+ hermes_kit/cli.py,sha256=dQuNqkECaYDsEdUsG89HMqDNEWLWLoYVfV1OHRZPvyA,7081
5
+ hermes_kit/hooks/cost_tracker/HOOK.yaml,sha256=gf-t2oRmRqicBQLWKDPAMTFn4LSFCPIX-pH9d3NaAYA,142
6
+ hermes_kit/hooks/cost_tracker/cost_tracker.yaml,sha256=UiKQ2yLuaWI7J7SN4s5jgZp9_dx00h9HJdbmDkEjxy8,25
7
+ hermes_kit/hooks/cost_tracker/handler.py,sha256=GCutxEh35CeingZPxavp-HIhiy9ZbWsG40-M0YHZFeY,1101
8
+ hermes_kit/hooks/fallback/HOOK.yaml,sha256=CmQARDcxjkE0tCcKsNTZMd0hf_JerNAN6FSZwyqtKo4,108
9
+ hermes_kit/hooks/fallback/fallback_chain.yaml,sha256=Uv5ayi9AkrmRySoeplK1j7oettBPufCf5qQ2RC4r7Is,130
10
+ hermes_kit/hooks/fallback/handler.py,sha256=qTPQWtfhnph3KgNlQTLSRi5RjsxSPykU53sS_MLfiFk,601
11
+ hermes_kit/hooks/rate_limiter/HOOK.yaml,sha256=BZ8R1Dvq4jgBqIr734uJRO6b3TOvWhGwh4uhYO7Kask,136
12
+ hermes_kit/hooks/rate_limiter/handler.py,sha256=K6xt0kr-6_FgCcoJSw3IX3fyGzDO-NRBCCoPVeuE9IQ,1433
13
+ hermes_kit/hooks/rate_limiter/rate_limits.yaml,sha256=3CwSafKtKAA3pleNb7PnNEhkGxL9QlbDFBUhRpGkUpY,174
14
+ hermes_kit/hooks/router/HOOK.yaml,sha256=5smejjub_n1vUfaMAsWWdfWBHXAiGXjYaZJLQQJngpQ,135
15
+ hermes_kit/hooks/router/handler.py,sha256=GrR2PQOr61OcRr9WuP1dQUOf3kC2i3MXhqT0fV1yYpA,1304
16
+ hermes_kit/hooks/router/topic_router.yaml,sha256=UwcdJSGilb4zkm0xAQLfKkLn6zdFaWdiOrLF-Q13AVk,129
17
+ hermes_kit/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ hermes_kit/lib/hermes_api.py,sha256=GEd8DNGH256zr9D_7p80kcvA9QD_0zNz-sBHa_kVqlI,167
19
+ hermes_agent_kit-0.2.1.dist-info/METADATA,sha256=KjFy9nWVtpVmBX_13rkhpjpGkN2mT-xBukZ6C80AxKk,4333
20
+ hermes_agent_kit-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
21
+ hermes_agent_kit-0.2.1.dist-info/entry_points.txt,sha256=JM-aLWkod-nGmOnUWMvY0h3cGmHU9SJTDK8m4nkoi6A,51
22
+ hermes_agent_kit-0.2.1.dist-info/top_level.txt,sha256=s9-u5hHpWuLxsybiueBHXzhJHMyZ-jGXB0ASvmkDcR0,11
23
+ hermes_agent_kit-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hermes-kit = hermes_kit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 srmdn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ hermes_kit
hermes_kit/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """hermes-kit — Production hardening hooks for Hermes Agent."""
hermes_kit/bridge.py ADDED
@@ -0,0 +1,168 @@
1
+ from typing import Optional
2
+ import time
3
+
4
+ _model_overrides: dict[str, dict[str, Optional[str]]] = {}
5
+ _fallback_chains: dict[str, list[str]] = {}
6
+ _fallback_index: dict[str, int] = {}
7
+
8
+ _rate_counters: dict[str, int] = {}
9
+ _rate_windows: dict[str, float] = {}
10
+ _rate_limited: set[str] = set()
11
+
12
+
13
+ def set_override(session_key: str, model: str, provider: Optional[str] = None) -> None:
14
+ _model_overrides[session_key] = {
15
+ "model": model,
16
+ "provider": provider,
17
+ }
18
+
19
+
20
+ def get_override(session_key: str) -> Optional[dict]:
21
+ return _model_overrides.get(session_key)
22
+
23
+
24
+ def clear_override(session_key: str) -> None:
25
+ _model_overrides.pop(session_key, None)
26
+
27
+
28
+ def set_fallback_chain(session_key: str, chain: list[str]) -> None:
29
+ _fallback_chains[session_key] = chain
30
+ _fallback_index[session_key] = 0
31
+
32
+
33
+ def get_fallback_chain(session_key: str) -> list[str] | None:
34
+ return _fallback_chains.get(session_key)
35
+
36
+
37
+ def advance_fallback(session_key: str) -> None:
38
+ if session_key in _fallback_index:
39
+ _fallback_index[session_key] += 1
40
+
41
+
42
+ def get_current_fallback(session_key: str) -> str | None:
43
+ chain = _fallback_chains.get(session_key)
44
+ if not chain:
45
+ return None
46
+ idx = _fallback_index.get(session_key, 0)
47
+ if idx < len(chain):
48
+ return chain[idx]
49
+ return None
50
+
51
+
52
+ def retry_with_fallback(session_key: str) -> str | None:
53
+ advance_fallback(session_key)
54
+ model = get_current_fallback(session_key)
55
+ if model:
56
+ set_override(session_key, model=model)
57
+ return model
58
+
59
+
60
+ def reset_rate_counter(session_key: str) -> None:
61
+ _rate_counters[session_key] = 0
62
+ _rate_windows[session_key] = time.time()
63
+ _rate_limited.discard(session_key)
64
+
65
+
66
+ def increment_rate_counter(session_key: str) -> int:
67
+ _rate_counters[session_key] = _rate_counters.get(session_key, 0) + 1
68
+ if session_key not in _rate_windows:
69
+ _rate_windows[session_key] = time.time()
70
+ return _rate_counters[session_key]
71
+
72
+
73
+ def get_rate_window_start(session_key: str) -> float:
74
+ return _rate_windows.get(session_key, 0.0)
75
+
76
+
77
+ def set_rate_limited(session_key: str) -> None:
78
+ _rate_limited.add(session_key)
79
+
80
+
81
+ def is_rate_limited(session_key: str) -> bool:
82
+ return session_key in _rate_limited
83
+
84
+
85
+ _session_costs: dict[str, dict[str, float]] = {}
86
+ _cost_pricing: dict[str, tuple[float, float]] = {
87
+ "gpt-4o": (2.50, 10.00),
88
+ "gpt-4o-mini": (0.15, 0.60),
89
+ "claude-sonnet-4": (3.00, 15.00),
90
+ "deepseek-chat": (0.14, 0.28),
91
+ "qwen-3.6-plus": (0.40, 0.80),
92
+ }
93
+
94
+
95
+ def track_cost(session_key: str, model: str, prompt_tokens: int, completion_tokens: int) -> None:
96
+ if session_key not in _session_costs:
97
+ _session_costs[session_key] = {}
98
+ if model not in _session_costs[session_key]:
99
+ _session_costs[session_key][model] = 0.0
100
+
101
+ input_price, output_price = _cost_pricing.get(model, (0.0, 0.0))
102
+ cost = (prompt_tokens / 1_000_000) * input_price + (completion_tokens / 1_000_000) * output_price
103
+ _session_costs[session_key][model] += cost
104
+
105
+
106
+ def get_session_cost(session_key: str) -> float:
107
+ if session_key not in _session_costs:
108
+ return 0.0
109
+ return sum(_session_costs[session_key].values())
110
+
111
+
112
+ def get_session_cost_breakdown(session_key: str) -> dict[str, float]:
113
+ return _session_costs.get(session_key, {})
114
+
115
+
116
+ def reset_session_cost(session_key: str) -> None:
117
+ _session_costs.pop(session_key, None)
118
+
119
+
120
+ def alert_cost_exceeded(session_key: str, total: float, threshold: float) -> None:
121
+ print(f"[hermes-kit] COST ALERT: session {session_key} total ${total:.4f} exceeds threshold ${threshold:.2f}")
122
+
123
+
124
+ _RUNTIME_KEYS = ("provider", "api_key", "base_url", "api_mode")
125
+
126
+
127
+ def _apply_override(override: dict, model: str, runtime_kwargs: dict) -> tuple[str, dict]:
128
+ model = override.get("model", model)
129
+ for key in _RUNTIME_KEYS:
130
+ val = override.get(key)
131
+ if val is not None:
132
+ runtime_kwargs[key] = val
133
+ return model, runtime_kwargs
134
+
135
+
136
+ def patch_gateway_resolver() -> None:
137
+ import inspect
138
+ from gateway.run import GatewayRunner
139
+
140
+ original = GatewayRunner._resolve_session_agent_runtime
141
+ original_is_async = inspect.iscoroutinefunction(original)
142
+
143
+ if original_is_async:
144
+ async def patched_resolver(self, *args, **kwargs):
145
+ model, runtime_kwargs = await original(self, *args, **kwargs)
146
+ session_key = kwargs.get("session_key") or (args[0] if args else None)
147
+ if session_key:
148
+ override = get_override(session_key)
149
+ if override:
150
+ model, runtime_kwargs = _apply_override(override, model, runtime_kwargs)
151
+ return model, runtime_kwargs
152
+ else:
153
+ def patched_resolver(self, *args, **kwargs):
154
+ model, runtime_kwargs = original(self, *args, **kwargs)
155
+ session_key = kwargs.get("session_key") or (args[0] if args else None)
156
+ if session_key:
157
+ override = get_override(session_key)
158
+ if override:
159
+ model, runtime_kwargs = _apply_override(override, model, runtime_kwargs)
160
+ return model, runtime_kwargs
161
+
162
+ GatewayRunner._resolve_session_agent_runtime = patched_resolver
163
+
164
+
165
+ try:
166
+ patch_gateway_resolver()
167
+ except ImportError:
168
+ pass
hermes_kit/cli.py ADDED
@@ -0,0 +1,240 @@
1
+ import shutil
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from hermes_kit.lib.hermes_api import get_hooks_dir
8
+
9
+
10
+ HOOKS_SRC = Path(__file__).parent / "hooks"
11
+
12
+ _hook_dir_names = [d.name for d in HOOKS_SRC.iterdir() if d.is_dir()]
13
+
14
+ _hook_names: dict[str, str] = {}
15
+ _hook_dirs: dict[str, str] = {}
16
+ for _dir in _hook_dir_names:
17
+ _hook_yaml = HOOKS_SRC / _dir / "HOOK.yaml"
18
+ if _hook_yaml.exists():
19
+ _name = yaml.safe_load(_hook_yaml.read_text()).get("name", _dir)
20
+ else:
21
+ _name = _dir
22
+ _hook_names[_dir] = _name
23
+ _hook_dirs[_name] = _dir
24
+
25
+ AVAILABLE_HOOKS = sorted(_hook_dirs.keys())
26
+
27
+
28
+ def install(hook_name: str) -> None:
29
+ dir_name = _hook_dirs.get(hook_name)
30
+ if not dir_name:
31
+ print(f"Unknown hook: {hook_name}. Available: {', '.join(AVAILABLE_HOOKS)}")
32
+ sys.exit(1)
33
+
34
+ src = HOOKS_SRC / dir_name
35
+ dest = Path(get_hooks_dir()) / hook_name
36
+ if dest.exists():
37
+ print(f"Hook '{hook_name}' already installed. Use 'hermes-kit reinstall {hook_name}' to overwrite.")
38
+ return
39
+
40
+ shutil.copytree(src, dest)
41
+ print(f"Installed '{hook_name}' → {dest}")
42
+
43
+
44
+ def doctor() -> None:
45
+ hooks_dir = Path(get_hooks_dir())
46
+ if not hooks_dir.exists():
47
+ print("OK: No hooks installed yet. Run 'hermes-kit install <name>' to add hooks.")
48
+ return
49
+
50
+ installed = [d.name for d in hooks_dir.iterdir() if d.is_dir()]
51
+ if not installed:
52
+ print("OK: Hooks directory exists but is empty.")
53
+ return
54
+
55
+ for name in installed:
56
+ hook_dir = hooks_dir / name
57
+ hook_yaml = hook_dir / "HOOK.yaml"
58
+ handler = hook_dir / "handler.py"
59
+
60
+ status = []
61
+ if hook_yaml.exists():
62
+ status.append("HOOK.yaml ✓")
63
+ else:
64
+ status.append("HOOK.yaml ✗ (missing)")
65
+ if handler.exists():
66
+ status.append("handler.py ✓")
67
+ else:
68
+ status.append("handler.py ✗ (missing)")
69
+
70
+ print(f" {name}: {' '.join(status)}")
71
+
72
+ print("Done.")
73
+
74
+
75
+ def gateway_run() -> None:
76
+ from hermes_kit.bridge import patch_gateway_resolver
77
+
78
+ patch_gateway_resolver()
79
+
80
+ sys.argv = ["hermes", "gateway", "run"] + sys.argv[3:]
81
+
82
+ from hermes_cli.main import main as hermes_main
83
+
84
+ hermes_main()
85
+
86
+
87
+ def _router_config_path() -> Path:
88
+ return Path(get_hooks_dir()) / "router" / "topic_router.yaml"
89
+
90
+
91
+ def _read_router_config() -> dict:
92
+ path = _router_config_path()
93
+ if path.exists():
94
+ return yaml.safe_load(path.read_text()) or {}
95
+ return {}
96
+
97
+
98
+ def _write_router_config(config: dict) -> None:
99
+ path = _router_config_path()
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
102
+
103
+
104
+ def router_add(topic_id: str, model: str, provider: str | None = None) -> None:
105
+ config = _read_router_config()
106
+ topics = config.get("topics") or {}
107
+ entry: dict[str, str] = {"model": model}
108
+ if provider:
109
+ entry["provider"] = provider
110
+ topics[topic_id] = entry
111
+ config["topics"] = topics
112
+ _write_router_config(config)
113
+ provider_msg = f" (provider: {provider})" if provider else ""
114
+ print(f"Added topic '{topic_id}' → {model}{provider_msg}")
115
+
116
+
117
+ def router_remove(topic_id: str) -> None:
118
+ config = _read_router_config()
119
+ topics = config.get("topics") or {}
120
+ if topic_id in topics:
121
+ del topics[topic_id]
122
+ _write_router_config(config)
123
+ print(f"Removed topic '{topic_id}'.")
124
+ else:
125
+ print(f"Topic '{topic_id}' not found.")
126
+
127
+
128
+ def router_show() -> None:
129
+ config = _read_router_config()
130
+ default = config.get("default") or {}
131
+ topics = config.get("topics") or {}
132
+
133
+ if default:
134
+ line = f"Default: {default.get('model', 'not set')}"
135
+ if default.get("provider"):
136
+ line += f" (provider: {default['provider']})"
137
+ print(line)
138
+ if topics:
139
+ if default:
140
+ print()
141
+ for tid, route in topics.items():
142
+ line = f" {tid} → {route.get('model', 'unknown')}"
143
+ if route.get("provider"):
144
+ line += f" (provider: {route['provider']})"
145
+ print(line)
146
+ if not default and not topics:
147
+ print("No routing configured.")
148
+
149
+
150
+ def router_set_default(model: str, provider: str | None = None) -> None:
151
+ config = _read_router_config()
152
+ entry: dict[str, str] = {"model": model}
153
+ if provider:
154
+ entry["provider"] = provider
155
+ config["default"] = entry
156
+ _write_router_config(config)
157
+ provider_msg = f" (provider: {provider})" if provider else ""
158
+ print(f"Default model: {model}{provider_msg}")
159
+
160
+
161
+ def _parse_flag(flag: str, args: list[str]) -> str | None:
162
+ try:
163
+ idx = args.index(flag)
164
+ return args[idx + 1]
165
+ except (ValueError, IndexError):
166
+ return None
167
+
168
+
169
+ def main() -> None:
170
+ if len(sys.argv) < 2:
171
+ print("Usage: hermes-kit <install|doctor|list|gateway run|router>")
172
+ sys.exit(1)
173
+
174
+ cmd = sys.argv[1]
175
+
176
+ if cmd == "install":
177
+ if len(sys.argv) < 3:
178
+ print(f"Usage: hermes-kit install <hook> [<hook> ...]. Available: {', '.join(AVAILABLE_HOOKS)}")
179
+ sys.exit(1)
180
+ for hook_name in sys.argv[2:]:
181
+ install(hook_name)
182
+
183
+ elif cmd == "doctor":
184
+ doctor()
185
+
186
+ elif cmd == "list":
187
+ print("Available hooks:")
188
+ for h in AVAILABLE_HOOKS:
189
+ installed = " (installed)" if (Path(get_hooks_dir()) / h).exists() else ""
190
+ print(f" {h}{installed}")
191
+
192
+ elif cmd == "gateway":
193
+ if len(sys.argv) < 3 or sys.argv[2] != "run":
194
+ print("Usage: hermes-kit gateway run [--accept-hooks] [-- ...]")
195
+ sys.exit(1)
196
+ gateway_run()
197
+
198
+ elif cmd == "router":
199
+ if len(sys.argv) < 3:
200
+ print("Usage: hermes-kit router <add|remove|show|set-default> [args]")
201
+ sys.exit(1)
202
+
203
+ subcmd = sys.argv[2]
204
+ args = sys.argv[3:]
205
+
206
+ if subcmd == "show":
207
+ router_show()
208
+
209
+ elif subcmd == "add":
210
+ topic_id = args[0] if args else None
211
+ model = _parse_flag("--model", args)
212
+ if not topic_id or not model:
213
+ print("Usage: hermes-kit router add <topic-id> --model <model> [--provider <provider>]")
214
+ sys.exit(1)
215
+ router_add(topic_id, model, provider=_parse_flag("--provider", args))
216
+
217
+ elif subcmd == "remove":
218
+ if not args:
219
+ print("Usage: hermes-kit router remove <topic-id>")
220
+ sys.exit(1)
221
+ router_remove(args[0])
222
+
223
+ elif subcmd == "set-default":
224
+ model = _parse_flag("--model", args)
225
+ if not model:
226
+ print("Usage: hermes-kit router set-default --model <model> [--provider <provider>]")
227
+ sys.exit(1)
228
+ router_set_default(model, provider=_parse_flag("--provider", args))
229
+
230
+ else:
231
+ print(f"Unknown router command: {subcmd}")
232
+ sys.exit(1)
233
+
234
+ else:
235
+ print(f"Unknown command: {cmd}")
236
+ sys.exit(1)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()
@@ -0,0 +1,5 @@
1
+ name: cost-tracker
2
+ description: Real-time token cost tracking with per-topic breakdown and budget alerts
3
+ events:
4
+ - agent:step
5
+ - agent:end
@@ -0,0 +1 @@
1
+ alert_threshold_usd: 1.0
@@ -0,0 +1,30 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from hermes_kit import bridge
4
+
5
+ _ALERT_THRESHOLD: float = 0.0
6
+ _hook_dir = Path(__file__).parent
7
+ _cfg_path = _hook_dir / "cost_tracker.yaml"
8
+ if _cfg_path.exists():
9
+ raw = yaml.safe_load(_cfg_path.read_text()) or {}
10
+ _ALERT_THRESHOLD = raw.get("alert_threshold_usd", 0.0)
11
+
12
+
13
+ async def handle(event_type: str, context: dict) -> None:
14
+ session_key = context.get("session_key")
15
+ if not session_key:
16
+ return
17
+
18
+ if event_type == "agent:step":
19
+ usage = context.get("usage", {})
20
+ if usage:
21
+ prompt_tokens = usage.get("prompt_tokens", 0)
22
+ completion_tokens = usage.get("completion_tokens", 0)
23
+ model = context.get("model", "unknown")
24
+ bridge.track_cost(session_key, model, prompt_tokens, completion_tokens)
25
+
26
+ elif event_type == "agent:end":
27
+ total = bridge.get_session_cost(session_key)
28
+ if total > 0 and _ALERT_THRESHOLD > 0 and total > _ALERT_THRESHOLD:
29
+ bridge.alert_cost_exceeded(session_key, total, _ALERT_THRESHOLD)
30
+ bridge.reset_session_cost(session_key)
@@ -0,0 +1,4 @@
1
+ name: fallback
2
+ description: Automatic model fallback chain when primary model fails
3
+ events:
4
+ - agent:start
@@ -0,0 +1,5 @@
1
+ chains:
2
+ # global:
3
+ # - "opencode-go/deepseek-v4-pro"
4
+ # - "opencode-go/claude-sonnet-4"
5
+ # - "opencode-go/gpt-4o-mini"
@@ -0,0 +1,23 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from hermes_kit import bridge
4
+
5
+ _CHAINS: dict[str, list[str]] = {}
6
+ _hook_dir = Path(__file__).parent
7
+ _chain_path = _hook_dir / "fallback_chain.yaml"
8
+ if _chain_path.exists():
9
+ raw = yaml.safe_load(_chain_path.read_text()) or {}
10
+ _CHAINS = raw.get("chains", {})
11
+
12
+
13
+ async def handle(event_type: str, context: dict) -> None:
14
+ if event_type != "agent:start":
15
+ return
16
+
17
+ session_key = context.get("session_key")
18
+ if not session_key:
19
+ return
20
+
21
+ chain = _CHAINS.get("global")
22
+ if chain:
23
+ bridge.set_fallback_chain(session_key, chain)
@@ -0,0 +1,5 @@
1
+ name: rate-limiter
2
+ description: Per-user and per-chat rate limiting to prevent API cost spikes
3
+ events:
4
+ - session:start
5
+ - agent:step
@@ -0,0 +1,48 @@
1
+ import time
2
+ import yaml
3
+ from pathlib import Path
4
+ from hermes_kit import bridge
5
+
6
+ _LIMITS: dict = {}
7
+ _hook_dir = Path(__file__).parent
8
+ _limits_path = _hook_dir / "rate_limits.yaml"
9
+ if _limits_path.exists():
10
+ raw = yaml.safe_load(_limits_path.read_text()) or {}
11
+ _LIMITS = raw.get("limits", {})
12
+
13
+
14
+ def _get_limit(user_id: str) -> dict | None:
15
+ per_user = _LIMITS.get("per_user", {})
16
+ if user_id in per_user:
17
+ return per_user[user_id]
18
+ return _LIMITS.get("global")
19
+
20
+
21
+ def _is_within_window(window_start: float, window_seconds: int) -> bool:
22
+ return (time.time() - window_start) < window_seconds
23
+
24
+
25
+ async def handle(event_type: str, context: dict) -> None:
26
+ session_key = context.get("session_key")
27
+ if not session_key:
28
+ return
29
+
30
+ limit = _get_limit(context.get("user_id", ""))
31
+ if not limit:
32
+ return
33
+
34
+ if event_type == "session:start":
35
+ bridge.reset_rate_counter(session_key)
36
+
37
+ elif event_type == "agent:step":
38
+ count = bridge.increment_rate_counter(session_key)
39
+ window_seconds = limit.get("window_seconds", 3600)
40
+ max_messages = limit.get("max_messages_per_window", 100)
41
+
42
+ window_start = bridge.get_rate_window_start(session_key)
43
+ if not _is_within_window(window_start, window_seconds):
44
+ bridge.reset_rate_counter(session_key)
45
+ return
46
+
47
+ if count > max_messages:
48
+ bridge.set_rate_limited(session_key)
@@ -0,0 +1,8 @@
1
+ limits:
2
+ global:
3
+ max_messages_per_window: 100
4
+ window_seconds: 3600
5
+ # per_user:
6
+ # "123456789":
7
+ # max_messages_per_window: 50
8
+ # window_seconds: 3600
@@ -0,0 +1,5 @@
1
+ name: router
2
+ description: Route messages to different AI models based on Telegram topic ID
3
+ events:
4
+ - session:start
5
+ - session:reset
@@ -0,0 +1,47 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from hermes_kit import bridge
4
+
5
+ _ROUTING: dict[str, dict] = {}
6
+ _DEFAULT: dict | None = None
7
+ _hook_dir = Path(__file__).parent
8
+ _routing_path = _hook_dir / "topic_router.yaml"
9
+ if _routing_path.exists():
10
+ raw = yaml.safe_load(_routing_path.read_text()) or {}
11
+ _ROUTING = raw.get("topics", {})
12
+ _DEFAULT = raw.get("default")
13
+
14
+
15
+ def _extract_routing_id(context: dict) -> str | None:
16
+ session_key = context.get("session_key", "")
17
+ chat_id = context.get("chat_id")
18
+ if not session_key:
19
+ return None
20
+
21
+ parts = session_key.split(":")
22
+ if len(parts) >= 5:
23
+ return parts[4]
24
+
25
+ return chat_id
26
+
27
+
28
+ async def handle(event_type: str, context: dict) -> None:
29
+ session_key = context.get("session_key")
30
+ if not session_key:
31
+ return
32
+
33
+ if event_type == "session:start":
34
+ routing_id = _extract_routing_id(context)
35
+ if routing_id:
36
+ route = _ROUTING.get(routing_id)
37
+ if not route:
38
+ route = _DEFAULT
39
+ if route:
40
+ bridge.set_override(
41
+ session_key,
42
+ model=route["model"],
43
+ provider=route.get("provider"),
44
+ )
45
+
46
+ elif event_type == "session:reset":
47
+ bridge.clear_override(session_key)
@@ -0,0 +1,7 @@
1
+ default:
2
+ model: "opencode-go/gpt-4o-mini"
3
+
4
+ topics:
5
+ # "42":
6
+ # model: "opencode-go/qwen-3.6-plus"
7
+ # profile: "finance"
File without changes
@@ -0,0 +1,6 @@
1
+ import os
2
+
3
+
4
+ def get_hooks_dir() -> str:
5
+ hermes_home = os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))
6
+ return os.path.join(hermes_home, "hooks")