hyperagents 1.2.3rc5.post3__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.
Files changed (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,551 @@
1
+ """Model catalog and model switching (docs/MODEL-SWITCHING.md).
2
+
3
+ - The Hyper model list comes from the gateway (``GET {gateway}/v1/catalog``
4
+ with the install key), cached with a TTL; offline it falls back to the last
5
+ good cache, then to the packaged HyperSpeed V2 entry. New Hyper models
6
+ appear without an app update.
7
+ - Third-party providers are merged here, client-side, from models.yaml —
8
+ never from the gateway — and labelled "your own provider (<name>)", or
9
+ "<model>, via Hyper" on a Hyper-issued key (hyperclaw.model_policy). A
10
+ Hyper-deployed install lists only the models on its allowed list
11
+ (hyperclaw.managed_config; default HyperSpeed V2 only), and choosing a
12
+ third-party model needs the owner's consent (docs/MODEL-POLICY.md).
13
+ - tools vs chat-only comes ONLY from signed qualification records
14
+ (``Provider.effective_capabilities``). The catalog never grants tools.
15
+ - A selection is per surface ("terminal", "telegram") and lasts for the
16
+ running process. ``selected_route`` is the hook the route selection uses.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import os
24
+ import re
25
+ import time
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Callable, Optional
29
+
30
+ log = logging.getLogger("hyperclaw.model_catalog")
31
+
32
+ TERMINAL = "terminal"
33
+ TELEGRAM = "telegram"
34
+
35
+ DEFAULT_TTL_S = 6 * 3600
36
+ FETCH_TIMEOUT_S = 5.0
37
+ CATALOG_URL_ENV = "HYPERCLAW_CATALOG_URL"
38
+ TTL_ENV = "HYPERCLAW_CATALOG_TTL_S"
39
+
40
+ SOURCE_GATEWAY = "Hyper gateway"
41
+ SOURCE_CACHE = "cached"
42
+ SOURCE_OFFLINE = "offline, last good copy"
43
+ SOURCE_PACKAGED = "built-in default"
44
+
45
+ # Ctrl-T cycles models in the terminal (docs/MODEL-SWITCHING.md).
46
+ # - libedit (macOS's Python): ^T calls readline's completion hook, which runs
47
+ # the switch in place and redraws the line; what was typed is kept. (A
48
+ # libedit string macro only replays its first key under Python's
49
+ # callback-driven input(), so a "/model next" macro cannot work there.)
50
+ # - GNU readline: a macro that clears the line and submits "/model next"
51
+ # (so a half-typed message is never sent).
52
+ SHORTCUT_LABEL = "Ctrl-T"
53
+ SHORTCUT_COMMAND = "/model next"
54
+ SHORTCUT_RAW = "\x14" # what input() returns for Ctrl-T without readline
55
+ _GNU_BINDING = r'"\C-t": "\C-a\C-k/model next\n"'
56
+ _LIBEDIT_BINDINGS = ("bind ^T rl_complete", "bind ^I ed-insert")
57
+
58
+ _ID = re.compile(r"^[A-Za-z0-9._:/-]{1,128}$")
59
+ _CTRL = re.compile(r"[\x00-\x1f\x7f]")
60
+
61
+ STATUS_TOOLS = "tools (qualified)"
62
+ STATUS_NOT_QUALIFIED = "chat-only (not qualified for tools)"
63
+ STATUS_CHAT = "chat-only"
64
+
65
+
66
+ # ───────────────────────── catalog ─────────────────────────
67
+
68
+ @dataclass
69
+ class CatalogResult:
70
+ models: list # sanitized entries (dicts)
71
+ source: str # SOURCE_*
72
+ fetched_at: Optional[float] = None
73
+
74
+
75
+ def ttl_s() -> float:
76
+ try:
77
+ return max(0.0, float(os.environ.get(TTL_ENV, "") or DEFAULT_TTL_S))
78
+ except ValueError:
79
+ return float(DEFAULT_TTL_S)
80
+
81
+
82
+ def cache_path() -> Path:
83
+ from hyperclaw.userconfig import root
84
+ return root() / "cache" / "model_catalog.json"
85
+
86
+
87
+ def packaged_default() -> list:
88
+ """The one entry every install has: HyperSpeed V2 as configured."""
89
+ from hyperclaw import hyperspeed
90
+ return [{"id": hyperspeed.model(), "display_name": hyperspeed.LABEL, "family": "hyper",
91
+ "modalities": ["text"], "context_window": None, "default": True,
92
+ "deprecated": False}]
93
+
94
+
95
+ def catalog_url() -> str:
96
+ """HYPERCLAW_CATALOG_URL, else the HyperSpeed base + /catalog. A raw RunPod
97
+ base has no catalog route, so it is never asked ("" = don't fetch)."""
98
+ explicit = os.environ.get(CATALOG_URL_ENV, "").strip()
99
+ if explicit:
100
+ return explicit
101
+ from hyperclaw import hyperspeed
102
+ base = hyperspeed.base_url().rstrip("/")
103
+ if not base or "runpod" in base.lower():
104
+ return ""
105
+ return base + "/catalog" if base.endswith("/v1") else base + "/v1/catalog"
106
+
107
+
108
+ def _key_fp(key: str) -> str:
109
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()[:12] if key else ""
110
+
111
+
112
+ def sanitize(payload) -> Optional[list]:
113
+ """Valid entries from a catalog response, or None if it isn't one.
114
+ Unknown fields (anything about tools/capabilities included) are dropped."""
115
+ if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
116
+ return None
117
+ out, seen = [], set()
118
+ for m in payload["models"]:
119
+ if not isinstance(m, dict):
120
+ continue
121
+ mid = m.get("id")
122
+ if not isinstance(mid, str) or not _ID.match(mid) or mid in seen:
123
+ continue
124
+ seen.add(mid)
125
+ name = m.get("display_name")
126
+ name = _CTRL.sub("", name)[:80].strip() if isinstance(name, str) else ""
127
+ fam = m.get("family")
128
+ mods = m.get("modalities")
129
+ cw = m.get("context_window")
130
+ out.append({
131
+ "id": mid,
132
+ "display_name": name or mid,
133
+ "family": _CTRL.sub("", fam)[:40] if isinstance(fam, str) and fam else "hyper",
134
+ "modalities": [x for x in mods if isinstance(x, str)][:8]
135
+ if isinstance(mods, list) else ["text"],
136
+ "context_window": cw if isinstance(cw, int) and not isinstance(cw, bool)
137
+ and cw > 0 else None,
138
+ "default": m.get("default") is True,
139
+ "deprecated": m.get("deprecated") is True,
140
+ })
141
+ return out
142
+
143
+
144
+ def _http_fetch(url: str, key: str, timeout: float):
145
+ import httpx
146
+ with httpx.Client(timeout=timeout) as client:
147
+ r = client.get(url, headers={"Authorization": f"Bearer {key}",
148
+ "Accept": "application/json"})
149
+ return r.status_code, (r.json() if r.status_code == 200 else None)
150
+
151
+
152
+ def _read_cache(url: str, key: str) -> Optional[dict]:
153
+ try:
154
+ data = json.loads(cache_path().read_text(encoding="utf-8"))
155
+ except Exception:
156
+ return None
157
+ if not isinstance(data, dict) or data.get("url") != url or data.get("key_fp") != _key_fp(key):
158
+ return None
159
+ models = sanitize({"models": data.get("models")})
160
+ if models is None or not isinstance(data.get("fetched_at"), (int, float)):
161
+ return None
162
+ return {"models": models, "fetched_at": float(data["fetched_at"])}
163
+
164
+
165
+ def _write_cache(url: str, key: str, models: list, now: float) -> None:
166
+ p = cache_path()
167
+ try:
168
+ p.parent.mkdir(parents=True, exist_ok=True)
169
+ tmp = p.with_suffix(".tmp")
170
+ tmp.write_text(json.dumps({"url": url, "key_fp": _key_fp(key), "fetched_at": now,
171
+ "models": models}, indent=1), encoding="utf-8")
172
+ os.chmod(tmp, 0o600)
173
+ os.replace(tmp, p)
174
+ except OSError as e:
175
+ log.warning(f"model catalog cache not written: {e}")
176
+
177
+
178
+ def get_catalog(fetch: Optional[Callable] = None, now: Optional[float] = None,
179
+ force: bool = False) -> CatalogResult:
180
+ """Fresh cache → gateway → last good cache → packaged default."""
181
+ from hyperclaw import hyperspeed
182
+ now = time.time() if now is None else now
183
+ url = catalog_url()
184
+ key = os.environ.get(hyperspeed.API_KEY_ENV, "").strip()
185
+ if not url or not key:
186
+ return CatalogResult(packaged_default(), SOURCE_PACKAGED)
187
+ cached = _read_cache(url, key)
188
+ if cached and not force and now - cached["fetched_at"] < ttl_s():
189
+ return CatalogResult(cached["models"], SOURCE_CACHE, cached["fetched_at"])
190
+ try:
191
+ status, payload = (fetch or _http_fetch)(url, key, FETCH_TIMEOUT_S)
192
+ models = sanitize(payload) if status == 200 else None
193
+ if models is None:
194
+ raise ValueError(f"catalog answered HTTP {status}" if status != 200
195
+ else "catalog response is malformed")
196
+ _write_cache(url, key, models, now)
197
+ return CatalogResult(models, SOURCE_GATEWAY, now)
198
+ except Exception as e: # never fatal: the list falls back, chat is unaffected
199
+ log.warning(f"model catalog unavailable ({type(e).__name__}: {str(e)[:120]})")
200
+ if cached:
201
+ return CatalogResult(cached["models"], SOURCE_OFFLINE, cached["fetched_at"])
202
+ return CatalogResult(packaged_default(), SOURCE_PACKAGED)
203
+
204
+
205
+ # ───────────────────────── listing ─────────────────────────
206
+
207
+ @dataclass(frozen=True)
208
+ class ModelOption:
209
+ provider: str
210
+ model: str
211
+ display_name: str
212
+ source: str # "HyperSpeed V2" | "your own provider (Anthropic)"
213
+ origin: str # hyper | customer
214
+ family: str
215
+ tools: bool # from the signed record only
216
+ status: str
217
+ default: bool = False
218
+ deprecated: bool = False
219
+ context_window: Optional[int] = None
220
+
221
+ @property
222
+ def key(self) -> str:
223
+ return f"{self.provider}/{self.model}"
224
+
225
+
226
+ def tool_status(prov, model: str) -> tuple:
227
+ """(tools?, label) from the signed record via effective_capabilities."""
228
+ if "tool_use" in prov.effective_capabilities(model):
229
+ return True, STATUS_TOOLS
230
+ if "tool_use" in prov.capabilities:
231
+ return False, STATUS_NOT_QUALIFIED
232
+ return False, STATUS_CHAT
233
+
234
+
235
+ def _customer_models(prov) -> list:
236
+ seen, out = set(), []
237
+ for m in [prov.model_for()] + list((prov.models or {}).values()):
238
+ if m and isinstance(m, str) and m not in seen:
239
+ seen.add(m)
240
+ out.append(m)
241
+ return out
242
+
243
+
244
+ def available_models(registry=None, catalog: Optional[CatalogResult] = None) -> list:
245
+ """Every model this install can use now: Hyper models first (catalog
246
+ order, plus the configured V2 model), then third-party providers
247
+ (Claude, OpenAI, Grok, ...). Hyper-deployed: only the models on the
248
+ install's allowed list (hyperclaw.managed_config; default V2 only)."""
249
+ from hyperclaw import hyperspeed
250
+ from hyperclaw.deployment import is_hyper
251
+ from hyperclaw.managed_config import allowed_for, is_allowed
252
+ from hyperclaw.model_policy import model_label
253
+ if registry is None:
254
+ from hyperclaw.providers import registry as _reg
255
+ registry = _reg()
256
+ hyper_only = is_hyper()
257
+ out: list = []
258
+
259
+ hp = registry.providers.get(hyperspeed.PROVIDER_NAME)
260
+ if hp is not None and hp.live and hp.origin == "hyper":
261
+ catalog = catalog or get_catalog()
262
+ entries = [e for e in catalog.models
263
+ if not hyper_only or e.get("family", "hyper") == "hyper"]
264
+ configured = hp.model_for()
265
+ if configured and all(e["id"] != configured for e in entries):
266
+ entries = entries + [{"id": configured, "display_name": hp.display_label,
267
+ "family": "hyper", "context_window": None,
268
+ "default": not any(e.get("default") for e in entries),
269
+ "deprecated": False}]
270
+ for e in entries:
271
+ if not is_allowed(hp.name, e["id"]):
272
+ continue
273
+ tools, status = tool_status(hp, e["id"])
274
+ out.append(ModelOption(hp.name, e["id"], e.get("display_name") or e["id"],
275
+ hp.display_label, "hyper", e.get("family") or "hyper",
276
+ tools, status, bool(e.get("default")),
277
+ bool(e.get("deprecated")), e.get("context_window")))
278
+
279
+ for name, prov in registry.providers.items():
280
+ if prov.origin == "hyper" or not prov.live or "chat" not in prov.capabilities:
281
+ continue
282
+ models = _customer_models(prov)
283
+ if hyper_only:
284
+ explicit = [m for m in allowed_for(name) if m != "*"]
285
+ models = list(dict.fromkeys(
286
+ [m for m in models if is_allowed(name, m)] + explicit))
287
+ for m in models:
288
+ tools, status = tool_status(prov, m)
289
+ out.append(ModelOption(name, m, m, model_label(prov, m), "customer",
290
+ prov.name, tools, status))
291
+ return out
292
+
293
+
294
+ def answering_line(options: list, active_key: str) -> str:
295
+ """'Answering now: <label> · model <id>' for the active model, '' if none."""
296
+ for o in options:
297
+ if o.key == active_key:
298
+ return f"Answering now: {o.source} · model {o.model}"
299
+ return ""
300
+
301
+
302
+ def format_listing(options: list, active_key: str, catalog: Optional[CatalogResult] = None,
303
+ surface: str = TERMINAL) -> str:
304
+ if not options:
305
+ return "No models are available on this install. Run `hyperclaw doctor`."
306
+ head = "Models on this install"
307
+ if catalog is not None:
308
+ when = (time.strftime(" %Y-%m-%d %H:%M", time.localtime(catalog.fetched_at))
309
+ if catalog.fetched_at else "")
310
+ head += f" (Hyper catalog: {catalog.source}{when})"
311
+ lines = [head + ":"]
312
+ for i, o in enumerate(options, 1):
313
+ name = o.display_name if o.display_name == o.model else f"{o.display_name} ({o.model})"
314
+ mark = "*" if o.key == active_key else " "
315
+ extra = " · deprecated" if o.deprecated else ""
316
+ tail = " ← active" if o.key == active_key else ""
317
+ lines.append(f"{mark} {i}. {name} · {o.source} · {o.status}{extra}{tail}")
318
+ answering = answering_line(options, active_key)
319
+ if answering:
320
+ lines.append(answering)
321
+ from hyperclaw.deployment import is_hyper
322
+ if is_hyper():
323
+ lines.append("Only the models on this install's allowed list can be selected.")
324
+ if surface == TELEGRAM:
325
+ lines.append("Switch: /model <number or id> · /model next · /model default. "
326
+ "Telegram replies are text only, whatever the model.")
327
+ else:
328
+ lines.append(f"Switch: /model <number or id> · {SHORTCUT_LABEL} or /model next · "
329
+ "/model default")
330
+ return "\n".join(lines)
331
+
332
+
333
+ def find_option(options: list, arg: str) -> Optional[ModelOption]:
334
+ arg = (arg or "").strip()
335
+ if not arg:
336
+ return None
337
+ if arg.isdigit():
338
+ i = int(arg)
339
+ return options[i - 1] if 1 <= i <= len(options) else None
340
+ for o in options:
341
+ if o.key == arg:
342
+ return o
343
+ hits = [o for o in options if o.model == arg] or \
344
+ [o for o in options if o.display_name.lower() == arg.lower()]
345
+ return hits[0] if len(hits) == 1 else None
346
+
347
+
348
+ def next_option(options: list, active_key: str) -> Optional[ModelOption]:
349
+ """The model after the active one, skipping deprecated ones, wrapping."""
350
+ usable = [o for o in options if not o.deprecated] or list(options)
351
+ if not usable:
352
+ return None
353
+ keys = [o.key for o in usable]
354
+ if active_key in keys:
355
+ return usable[(keys.index(active_key) + 1) % len(usable)]
356
+ return usable[0]
357
+
358
+
359
+ # ───────────────────────── selection (route hook) ─────────────────────────
360
+
361
+ _SELECTION: dict = {} # surface -> (provider name, model id)
362
+
363
+
364
+ def select(surface: str, provider: str, model: str) -> None:
365
+ _SELECTION[surface] = (provider, model)
366
+
367
+
368
+ def clear(surface: Optional[str] = None) -> None:
369
+ if surface is None:
370
+ _SELECTION.clear()
371
+ else:
372
+ _SELECTION.pop(surface, None)
373
+
374
+
375
+ def selection(surface: str) -> Optional[tuple]:
376
+ return _SELECTION.get(surface)
377
+
378
+
379
+ def is_selected(surface: str, provider: str, model: str) -> bool:
380
+ return _SELECTION.get(surface) == (provider, model)
381
+
382
+
383
+ def selected_route(registry, surface: str) -> Optional[tuple]:
384
+ """(Provider, model) for this surface's selection, or None (no selection,
385
+ or it is no longer usable: provider gone or not live, not on a
386
+ Hyper-deployed install's allowed list, or a third-party provider without
387
+ the owner's consent)."""
388
+ sel = _SELECTION.get(surface)
389
+ if not sel:
390
+ return None
391
+ from hyperclaw.managed_config import is_allowed
392
+ from hyperclaw.model_policy import needs_consent
393
+ prov = getattr(registry, "providers", {}).get(sel[0])
394
+ if prov is None or not prov.live or "chat" not in prov.capabilities:
395
+ return None
396
+ if not is_allowed(prov.name, sel[1]) or needs_consent(prov):
397
+ return None
398
+ return prov, sel[1]
399
+
400
+
401
+ def prefer_selected(registry, surface: str, candidates: list, need=()) -> list:
402
+ """Put the surface's selected model first in a slot ladder when it can
403
+ serve the turn (declared + qualified capabilities); else the ladder."""
404
+ sel = selected_route(registry, surface)
405
+ if not sel:
406
+ return list(candidates)
407
+ prov, model = sel
408
+ if not frozenset(need or ()) <= prov.effective_capabilities(model):
409
+ return list(candidates)
410
+ return [(prov, model)] + [(p, m) for p, m in candidates
411
+ if (p.name, m) != (prov.name, model)]
412
+
413
+
414
+ def route_key(route) -> str:
415
+ """'provider/model' for a (mode, provider, model) route, '' if none."""
416
+ if not route or route[1] is None:
417
+ return ""
418
+ return f"{getattr(route[1], 'name', '?')}/{route[2] or ''}"
419
+
420
+
421
+ # ───────────────────────── /model command ─────────────────────────
422
+
423
+ @dataclass
424
+ class CommandResult:
425
+ text: str
426
+ switched: Optional[ModelOption] = None
427
+ cleared: bool = False
428
+ needs_consent: bool = False
429
+
430
+
431
+ CONSENT_WORDS = ("yes", "agree")
432
+
433
+
434
+ def run_command(args: list, surface: str, active_key: str, registry=None,
435
+ catalog: Optional[CatalogResult] = None,
436
+ confirm: Optional[Callable] = None) -> CommandResult:
437
+ """/model, /model next, /model default, /model <number|id|provider/id>.
438
+ Selection only; the caller re-applies the route, prompt and log.
439
+
440
+ A third-party model shows the one consent line (model_policy). Without
441
+ recorded consent the owner must agree: ``confirm(line) -> bool`` (the
442
+ terminal asks), or a trailing "yes" (``/model 3 yes`` on Telegram). No
443
+ consent, no switch."""
444
+ from hyperclaw import model_policy as mp
445
+ catalog = catalog or get_catalog()
446
+ options = available_models(registry, catalog)
447
+ if not args:
448
+ return CommandResult(format_listing(options, active_key, catalog, surface))
449
+ agreed = len(args) > 1 and args[-1].strip().lower() in CONSENT_WORDS
450
+ if agreed:
451
+ args = args[:-1]
452
+ arg = " ".join(args).strip()
453
+ if arg.lower() in ("default", "reset"):
454
+ clear(surface)
455
+ return CommandResult("Back to this install's default model.", cleared=True)
456
+ choice = next_option(options, active_key) if arg.lower() == "next" \
457
+ else find_option(options, arg)
458
+ if choice is None:
459
+ return CommandResult(f"No model {arg!r} on this install.\n"
460
+ + format_listing(options, active_key, catalog, surface))
461
+ if choice.key == active_key:
462
+ return CommandResult(f"Already using {choice.display_name}.")
463
+ consent = ""
464
+ if choice.origin != "hyper":
465
+ consent = mp.consent_line(choice.provider)
466
+ if not mp.has_consent(choice.provider):
467
+ ok = agreed or (confirm is not None and bool(confirm(consent)))
468
+ if not ok:
469
+ how = (f"To go ahead, send /model {choice.key} yes" if confirm is None
470
+ else "Nothing changed.")
471
+ return CommandResult(f"{consent}\n{how}", needs_consent=True)
472
+ mp.record_consent(choice.provider, choice.model)
473
+ consent = "" # already shown by confirm, or just agreed to
474
+ select(surface, choice.provider, choice.model)
475
+ return CommandResult((consent + "\n" if consent else "")
476
+ + f"Switched to {choice.display_name} · {choice.source} · "
477
+ f"{choice.status}.", switched=choice)
478
+
479
+
480
+ def record_switch(surface: str, old_key: str, new_key: str, mode: str,
481
+ kept: int) -> None:
482
+ """Activity log entry (spec rule 6) for a model switch."""
483
+ try:
484
+ from hyperclaw.tools.ledger import get_ledger
485
+ get_ledger().record(conversation=surface, turn="", surface=surface,
486
+ tool="model_switch", effect="config",
487
+ target=f"{old_key or '(none)'} -> {new_key} ({mode})",
488
+ status="ok",
489
+ message=f"history kept ({kept} messages); mode {mode}")
490
+ except Exception as e:
491
+ log.warning(f"model switch not written to the activity log: {e}")
492
+
493
+
494
+ # ───────────────────────── history for a chat-only model ─────────────────────────
495
+
496
+ def chat_only_messages(history: list) -> list:
497
+ """History for a chat-only model on the Messages API: tool calls and
498
+ results flattened to labelled text (truth_guard.history_to_openai
499
+ chat-only path), same-role neighbours merged, no tool block types."""
500
+ from hyperclaw.truth_guard import history_to_openai
501
+ out: list = []
502
+ for m in history_to_openai(history, native_tools=False):
503
+ role = "assistant" if m.get("role") == "assistant" else "user"
504
+ text = m.get("content") or ""
505
+ if not isinstance(text, str) or not text.strip():
506
+ continue
507
+ if out and out[-1]["role"] == role:
508
+ out[-1]["content"] += "\n\n" + text
509
+ else:
510
+ out.append({"role": role, "content": text})
511
+ while out and out[0]["role"] != "user":
512
+ out.pop(0)
513
+ return out
514
+
515
+
516
+ # ───────────────────────── terminal shortcut ─────────────────────────
517
+
518
+ def shortcut_bindings(libedit: bool) -> tuple:
519
+ return _LIBEDIT_BINDINGS if libedit else (_GNU_BINDING,)
520
+
521
+
522
+ def install_shortcut(on_shortcut: Optional[Callable] = None, readline_mod=None) -> bool:
523
+ """Bind Ctrl-T for input(). libedit needs ``on_shortcut`` (run in place);
524
+ GNU readline submits '/model next'. False if not installed."""
525
+ try:
526
+ rl = readline_mod
527
+ if rl is None:
528
+ import readline as rl # noqa: F811
529
+ libedit = "libedit" in (getattr(rl, "__doc__", "") or "") or \
530
+ getattr(rl, "backend", "") == "editline"
531
+ if libedit:
532
+ if on_shortcut is None:
533
+ return False
534
+
535
+ def _hook(text, state):
536
+ if state == 0:
537
+ try:
538
+ on_shortcut()
539
+ except Exception as e: # never break the input line
540
+ print(f"\n[model switch failed: {e}]")
541
+ try:
542
+ rl.redisplay()
543
+ except Exception:
544
+ pass
545
+ return None
546
+ rl.set_completer(_hook)
547
+ for b in shortcut_bindings(libedit):
548
+ rl.parse_and_bind(b)
549
+ return True
550
+ except Exception:
551
+ return False