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,938 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TUI Bridge — Expose TUI's full capabilities to external systems (Telegram, API, etc.)
4
+
5
+ This module wraps the TUI's chat function for programmatic access with:
6
+ - Async interface for non-blocking execution
7
+ - Output capture (text, tool results, screenshots)
8
+ - Per-session context isolation
9
+ - Structured response format
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import sys
15
+ import io
16
+ import re
17
+ import os
18
+ import anthropic
19
+ from pathlib import Path
20
+ from typing import Optional, Dict, Any, List
21
+ from datetime import datetime
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ import threading
24
+
25
+ from hyperclaw.chat_scope import (GUEST, GUEST_RULES, GUEST_SAFE_TOOLS, OWNER,
26
+ guest_tools, scope_for)
27
+ from hyperclaw.chat_scope import normalize as normalize_scope
28
+ from hyperclaw.notices import failure_notice
29
+ from hyperclaw.tools.surfaces import NEVER_FROM_CHAT, tools_for_surface
30
+ from hyperclaw.tools.envelope import REASON_SURFACE, Status, make as make_envelope
31
+ from hyperclaw.tools.registry import ToolTimeout
32
+ from hyperclaw.tools.guard import TurnGuard, chat_tool_call_cap
33
+
34
+ # This bridge serves remote chats (Telegram, iMessage). Shell/code execution
35
+ # and desktop control are not offered here at all (spec rule 4, §6).
36
+ SURFACE = "bridge"
37
+
38
+ # Constants
39
+ HYPERCLAW_ROOT = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw"))
40
+ SCREENSHOTS_DIR = HYPERCLAW_ROOT / "screenshots"
41
+ SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
42
+
43
+ # Thread pool for running sync TUI in async context
44
+ _executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="tui_bridge")
45
+
46
+ # Per-session state (chat_id -> history)
47
+ _session_histories: Dict[int, List[dict]] = {}
48
+ _session_lock = threading.Lock()
49
+
50
+
51
+ class TUIBridge:
52
+ """
53
+ Bridge for external systems to access TUI's full 80+ tool capabilities.
54
+
55
+ Usage:
56
+ bridge = TUIBridge()
57
+ result = await bridge.execute("list files in Downloads", chat_id=12345)
58
+ print(result['text'])
59
+ for screenshot in result.get('screenshots', []):
60
+ send_photo(screenshot)
61
+ """
62
+
63
+ def __init__(self):
64
+ self.api_key = os.environ.get("ANTHROPIC_API_KEY")
65
+ self.model = os.environ.get("HYPERCLAW_MODEL", "claude-fable-5")
66
+ self._load_system_prompt()
67
+ self._load_tools()
68
+
69
+ def _usable(self) -> str:
70
+ """"" when this bridge can serve a turn, else why not. The bridge
71
+ talks to Anthropic only; a Hyper-deployed install is V2-only, so the
72
+ bridge refuses there rather than use a third-party provider."""
73
+ from hyperclaw.deployment import is_hyper
74
+ if is_hyper():
75
+ return "hyper_deployed"
76
+ if not getattr(self, "api_key", None):
77
+ return "not_configured"
78
+ return ""
79
+
80
+ def _tools_allowed(self) -> bool:
81
+ """Tools only for a qualified model (spec §11), never when unusable."""
82
+ if self._usable():
83
+ return False
84
+ from hyperclaw.qualification import is_qualified
85
+ return is_qualified("anthropic", getattr(self, "model", ""))
86
+
87
+ def _identity_names(self):
88
+ from hyperclaw.userconfig import load_config
89
+ cfg = load_config()
90
+ ai = (cfg.get("ai_name") or os.environ.get("ASSISTANT_NAME") or "Assistant")
91
+ owner = (cfg.get("user_name") or os.environ.get("OWNER_NAME") or "")
92
+ return ai, owner
93
+
94
+ def _load_system_prompt(self):
95
+ """Load system prompts from workspace files (owner) and the fenced
96
+ guest prompt (no owner context at all)."""
97
+ self.system_prompt = self._build_system_prompt()
98
+ self.guest_system_prompt = self._build_guest_system_prompt()
99
+
100
+ def _access(self, scope: str = GUEST) -> str:
101
+ from hyperclaw.truth_guard import access_manifest
102
+ mode = "tools" if self._tools_allowed() else "chat_only"
103
+ return access_manifest(mode, "anthropic", getattr(self, "model", ""), surface="bridge",
104
+ scope=scope)
105
+
106
+ def _owner_system_with_memories(self, memory_context: str) -> str:
107
+ """Owner system prompt + recalled memories, fenced as DATA after the
108
+ truth rules (benchmark 1.6: recalled text never becomes instructions)."""
109
+ if not memory_context or not memory_context.strip():
110
+ return self.system_prompt
111
+ from hyperclaw.prompt_assembly import fence_data
112
+ return self.system_prompt + "\n\n" + fence_data(memory_context, "relevant memories")
113
+
114
+ def _build_guest_system_prompt(self) -> str:
115
+ """System prompt for a non-owner chat: no persona, no owner name,
116
+ no MEMORY/USER/IDENTITY files, no handoff (spec §6 guest fence)."""
117
+ from hyperclaw.prompt_assembly import assemble, identity_block
118
+ from hyperclaw.truth_guard import TRUTH_RULES
119
+ ai_name, _ = self._identity_names()
120
+ return assemble(
121
+ truth_rules=TRUTH_RULES, access=self._access(GUEST),
122
+ identity=identity_block(ai_name, "", GUEST), scope=GUEST,
123
+ surface_instructions=("Context:\nThis request is coming via a chat channel. "
124
+ "Keep responses under 2000 characters."))
125
+
126
+ def _build_system_prompt(self) -> str:
127
+ """Build the full (OWNER) system prompt (hyperclaw.prompt_assembly
128
+ order: truth rules and access first, persona and memory after)."""
129
+ from hyperclaw.prompt_assembly import assemble, identity_block, persona_for
130
+ from hyperclaw.truth_guard import TRUTH_RULES
131
+ ai_name, owner_name = self._identity_names()
132
+
133
+ # Legacy persona file (PERSONA_FILE or workspace/persona.md): kept as
134
+ # the owner's standing instructions, below the truth rules.
135
+ standing = ""
136
+ persona_path = os.environ.get("PERSONA_FILE", "") or str(HYPERCLAW_ROOT / "workspace" / "persona.md")
137
+ try:
138
+ p = Path(persona_path)
139
+ if p.exists():
140
+ standing = p.read_text()[:4000]
141
+ except Exception:
142
+ standing = ""
143
+
144
+ surface = """
145
+ You have FULL ACCESS to the computer via tools. Execute requests directly - don't ask permission for routine tasks.
146
+
147
+ CRITICAL EXECUTION RULES:
148
+ - DO IT NOW. Never say "I will" or "I can" or "I'll" - just DO IT.
149
+ - TOOLS FIRST. Call the tool, THEN report what you did. Action before words.
150
+ - NO DEFERRING. There is no "later". If asked to do something, do it THIS response.
151
+ - FOLLOW THROUGH. If you say something will happen, make it happen immediately.
152
+ - NO PROCRASTINATION. Every request gets executed, not acknowledged.
153
+
154
+ BAD: "I can create that presentation for you"
155
+ GOOD: [calls create_presentation tool] "Done. Created deck at ~/Desktop/presentation.pptx"
156
+
157
+ BAD: "I'll send that email"
158
+ GOOD: [calls gmail_send tool] "Sent to user@example.com"
159
+
160
+ BAD: "Let me check your calendar" [doesn't call tool]
161
+ GOOD: [calls calendar_read tool] "You have 3 meetings tomorrow..."
162
+
163
+ Capabilities:
164
+ - File read/write/edit
165
+ - Email (Gmail) read/send/search
166
+ - Calendar management
167
+ - Create presentations, documents, spreadsheets
168
+ - Vision on images you are given
169
+ - Web browsing and search
170
+ - iMessage and Telegram
171
+ - NOT available from chat: shell commands, running code, AppleScript, screenshots,
172
+ or controlling apps, mouse or keyboard on the computer. If asked, say these only
173
+ work in the HyperClaw terminal on the computer itself.
174
+ - FILE DELIVERY: send_file delivers any file (PDF, deck, doc, sheet, image) INTO this
175
+ conversation (via='here'), or via telegram/imessage/email/open. When you create a
176
+ document or image and the user is on Telegram/iMessage, ALWAYS send_file it back
177
+ to them here - never just report a local path they can't open from their phone.
178
+ - EMAIL: full Gmail suite - email_send (supports body_html + attachments), email_forward,
179
+ email_draft (creates a Gmail draft for review instead of sending), email_mark
180
+ (read/archive/star), email_search, email_thread, email_download_attachment.
181
+
182
+ Communication Style:
183
+ - Execute first, report after. Always.
184
+ - Write like a human texting, not a robot.
185
+ - Concise responses. No filler.
186
+ - No corporate phrases ("I'd be happy to help", "Great question!")
187
+
188
+ Context:
189
+ This request is coming via a chat channel. Keep responses under 2000 characters. Include key info, skip verbose explanations.
190
+ """
191
+ context = []
192
+ workspace = HYPERCLAW_ROOT / "workspace"
193
+ for filename in ["MEMORY.md", "USER.md", "IDENTITY.md"]:
194
+ filepath = workspace / filename
195
+ if filepath.exists():
196
+ try:
197
+ content = filepath.read_text()[:3000] # Limit size
198
+ context.append(f"## {filename}\n{content}")
199
+ except:
200
+ pass
201
+
202
+ return assemble(truth_rules=TRUTH_RULES, access=self._access(OWNER),
203
+ identity=identity_block(ai_name, owner_name, OWNER,
204
+ model=getattr(self, "model", "")),
205
+ scope=OWNER, surface_instructions=surface,
206
+ persona=persona_for(OWNER), standing=standing, context=context)
207
+
208
+ # Tools allowed to run long (doc/media generation, research); everything else 120s.
209
+ _TOOL_TIMEOUTS = {
210
+ "create_document": 300, "create_presentation": 300,
211
+ "create_spreadsheet": 300, "zimage_generate": 300, "deep_research": 600,
212
+ }
213
+ _DEFAULT_TOOL_TIMEOUT = int(os.environ.get("HYPERCLAW_TOOL_TIMEOUT", "120"))
214
+
215
+ def _load_tools(self):
216
+ """Load tool definitions from TUI."""
217
+ # Import tools from TUI module
218
+ try:
219
+ from . import tui
220
+ tools = tui.TOOLS
221
+ self._raw_execute_tool = tui.execute_tool
222
+ self.registry = tui.REGISTRY
223
+ except ImportError:
224
+ # Fallback: define essential tools inline
225
+ tools = self._get_essential_tools()
226
+ self._raw_execute_tool = self._execute_tool_fallback
227
+ self.registry = None
228
+ # Removed, not gated: never_from_chat tools are not in this surface's list.
229
+ self.tools = tools_for_surface(tools, SURFACE)
230
+ if not self._tools_allowed():
231
+ self.tools = [] # spec §11: chat-only until the model is qualified
232
+ self.execute_tool = self._execute_tool_with_timeout
233
+
234
+ def _run_with_timeout(self, spec, args):
235
+ """Registry `run` hook: the tool runs in the worker pool under its time
236
+ budget; a timeout raises ToolTimeout, which the registry turns into
237
+ `uncertain` for side-effecting tools (never retried) and `failed` for
238
+ reads."""
239
+ import concurrent.futures as _cf
240
+ import contextvars as _cv
241
+ timeout = self._TOOL_TIMEOUTS.get(spec.name, self._DEFAULT_TOOL_TIMEOUT)
242
+ # The tool thread runs in this turn's context, so a model call a tool
243
+ # makes (sub-agent, research) is charged to this task's cost cap.
244
+ future = self._tool_executor.submit(_cv.copy_context().run, self._raw_execute_tool,
245
+ spec.name, args)
246
+ try:
247
+ return future.result(timeout=timeout)
248
+ except _cf.TimeoutError:
249
+ import logging
250
+ logging.getLogger("tui_bridge").error(
251
+ f"Tool '{spec.name}' exceeded {timeout}s — abandoning worker thread")
252
+ raise ToolTimeout(f"after {timeout}s")
253
+
254
+ def dispatch_tool(self, name, input_data, scope=GUEST, guard=None):
255
+ """Run one model tool call for this surface and scope; always returns
256
+ the structured envelope (tool standard S2). `guard` is the turn's
257
+ TurnGuard (wave 2: effect ledger, retry budgets, chat call cap)."""
258
+ registry = getattr(self, "registry", None)
259
+ if registry is not None:
260
+ return registry.dispatch(name, input_data, surface=SURFACE, scope=scope,
261
+ run=self._run_with_timeout, guard=guard)
262
+ # Fallback (no TUI registry importable): legacy string, classified generically.
263
+ from hyperclaw.tools.classify import classify
264
+ import types as _types
265
+ try:
266
+ raw = self.execute_tool(name, input_data)
267
+ except Exception as e:
268
+ return make_envelope(Status.FAILED, f"Error: tool {name} failed: {e}")
269
+ return classify(_types.SimpleNamespace(name=name, effect="system"), raw, input_data)
270
+
271
+ # One shared single-slot pool per bridge would serialize tools; use a small pool.
272
+ _tool_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="tui_tool")
273
+
274
+ def _execute_tool_with_timeout(self, name, input_data):
275
+ """Run a tool with a hard timeout; a hung tool becomes an error result,
276
+ never a hung conversation. (Python threads can't be killed — the orphaned
277
+ worker is logged and abandoned; subprocess-backed tools should also pass
278
+ their own subprocess timeouts.)"""
279
+ import concurrent.futures as _cf
280
+ if name in NEVER_FROM_CHAT: # defence in depth: never dispatched from chat
281
+ return (f"Error: tool '{name}' is not available from chat. It was NOT run. "
282
+ f"Tell the user it only works in the HyperClaw terminal on the computer.")
283
+ timeout = self._TOOL_TIMEOUTS.get(name, self._DEFAULT_TOOL_TIMEOUT)
284
+ future = self._tool_executor.submit(self._raw_execute_tool, name, input_data)
285
+ try:
286
+ return future.result(timeout=timeout)
287
+ except _cf.TimeoutError:
288
+ import logging
289
+ logging.getLogger("tui_bridge").error(
290
+ f"Tool '{name}' exceeded {timeout}s — abandoning worker thread")
291
+ return (f"Error: tool '{name}' timed out after {timeout}s. "
292
+ f"It may still be running in the background; do not retry blindly.")
293
+
294
+ # Universal tools always available regardless of the message (the workhorses).
295
+ _CORE_TOOLS = {
296
+ "read_file", "write_file", "edit_file", "glob", "grep",
297
+ "web_search", "web_fetch", "http_request", "telegram", "speak",
298
+ "system_info", "time_info", "weather", "news", "memory_search", "memory_store",
299
+ "memory_list", "task_create", "task_list", "task_update", "delegate_to_agent",
300
+ "swarm_dispatch", "generate_briefing", "notification", "send_file",
301
+ "team_delegate",
302
+ }
303
+ # Map intent words in the user's message to tool-name tokens (covers synonyms).
304
+ _SYNONYMS = {
305
+ "mail": "email", "inbox": "email", "reply": "email", "draft": "email",
306
+ "meeting": "calendar", "schedule": "calendar", "event": "calendar", "appointment": "calendar",
307
+ "deck": "presentation", "slides": "presentation", "powerpoint": "presentation", "ppt": "presentation",
308
+ "word": "document", "letter": "document", "sheet": "spreadsheet", "excel": "spreadsheet",
309
+ "website": "browser", "url": "browser", "webpage": "browser", "repo": "git", "commit": "git",
310
+ "pr": "github", "remind": "reminders", "note": "notes", "picture": "image", "photo": "image",
311
+ "trade": "trading", "position": "trading", "voice": "speak", "say": "speak",
312
+ }
313
+
314
+ def _select_tools(self, message: str) -> list:
315
+ """Return a focused subset of tools relevant to the message (reduces mis-selection vs
316
+ dumping all 220 tools). Fail-safe by design: too little signal, too few matches, or a
317
+ broad request all fall back to the FULL tool set, so a needed tool is never hidden.
318
+ Disable entirely with env HYPERCLAW_TOOL_ROUTING=off."""
319
+ import re
320
+ tools = self.tools
321
+ if os.environ.get("HYPERCLAW_TOOL_ROUTING", "on").lower() in ("off", "0", "false", "no"):
322
+ return tools
323
+ if not tools:
324
+ return tools
325
+ words = set(re.findall(r"[a-z0-9]{3,}", (message or "").lower()))
326
+ if len(words) < 2:
327
+ return tools # not enough signal to route safely -> give everything
328
+ words |= {self._SYNONYMS[w] for w in list(words) if w in self._SYNONYMS}
329
+
330
+ selected = []
331
+ for t in tools:
332
+ name = t.get("name", "")
333
+ if name in self._CORE_TOOLS:
334
+ selected.append(t)
335
+ continue
336
+ name_tokens = set(name.lower().replace("_", " ").split())
337
+ if words & name_tokens:
338
+ selected.append(t)
339
+
340
+ # Fail-safes: keep the full set if routing didn't help or could starve the model.
341
+ if len(selected) < 15 or len(selected) > 70 or len(selected) >= len(tools) - 5:
342
+ return tools
343
+ return selected
344
+
345
+ def _get_essential_tools(self) -> list:
346
+ """Essential tools if TUI import fails (no shell: this is a chat surface)."""
347
+ return [
348
+ {
349
+ "name": "read_file",
350
+ "description": "Read a file's contents",
351
+ "input_schema": {
352
+ "type": "object",
353
+ "properties": {
354
+ "path": {"type": "string", "description": "File path to read"}
355
+ },
356
+ "required": ["path"]
357
+ }
358
+ }
359
+ ]
360
+
361
+ def _execute_tool_fallback(self, name: str, input_data: dict) -> str:
362
+ """Fallback tool execution."""
363
+ if name == "read_file":
364
+ try:
365
+ return Path(input_data["path"]).read_text()[:10000]
366
+ except Exception as e:
367
+ return f"Error: {e}"
368
+ return f"Tool {name} not available in fallback mode"
369
+
370
+ def get_session_history(self, chat_id: int) -> List[dict]:
371
+ """Get conversation history for a session."""
372
+ with _session_lock:
373
+ if chat_id not in _session_histories:
374
+ _session_histories[chat_id] = []
375
+ return _session_histories[chat_id]
376
+
377
+ def add_to_history(self, chat_id: int, role: str, content: Any):
378
+ """Add message to session history."""
379
+ with _session_lock:
380
+ if chat_id not in _session_histories:
381
+ _session_histories[chat_id] = []
382
+ _session_histories[chat_id].append({"role": role, "content": content})
383
+ # Keep last 30 exchanges (60 messages)
384
+ if len(_session_histories[chat_id]) > 60:
385
+ _session_histories[chat_id] = _session_histories[chat_id][-60:]
386
+
387
+ def clear_session(self, chat_id: int):
388
+ """Clear session history."""
389
+ with _session_lock:
390
+ _session_histories[chat_id] = []
391
+ from hyperclaw import taint
392
+ taint.clear(f"{SURFACE}:{chat_id}") # the history (and its connector content) is gone
393
+
394
+ def _get_relevant_memories(self, message: str) -> str:
395
+ """Get relevant memories from vector storage for the given message."""
396
+ memory_parts = []
397
+
398
+ # 1. Try MemoryBus for cross-channel context
399
+ try:
400
+ from .memory_bus import get_memory_bus
401
+ bus = get_memory_bus()
402
+
403
+ # Get semantically relevant memories
404
+ context = bus.get_context(message, time_range_hours=48, limit=5)
405
+ if context:
406
+ memory_parts.append(context)
407
+ except Exception:
408
+ pass
409
+
410
+ # 2. Fallback to persistent_memory if MemoryBus fails
411
+ if not memory_parts:
412
+ try:
413
+ from .persistent_memory import recall_relevant_memories
414
+ memories = recall_relevant_memories(message, limit=5)
415
+ if memories:
416
+ lines = []
417
+ for m in memories:
418
+ content = m.get('content', '')[:300]
419
+ lines.append(f"- {content}")
420
+ memory_parts.append("\n".join(lines))
421
+ except Exception:
422
+ pass
423
+
424
+ return "\n\n".join(memory_parts) if memory_parts else ""
425
+
426
+ async def execute(
427
+ self,
428
+ message: str,
429
+ chat_id: int,
430
+ include_history: bool = True,
431
+ scope: Optional[str] = None,
432
+ ) -> Dict[str, Any]:
433
+ """
434
+ Execute a message through TUI with full tool access.
435
+
436
+ Args:
437
+ message: User's message/request
438
+ chat_id: Session identifier (Telegram chat_id)
439
+ include_history: Whether to include conversation history
440
+ scope: chat_scope.OWNER or GUEST. None resolves the Telegram
441
+ owner identity for chat_id; unknown -> GUEST (fail closed).
442
+
443
+ Returns:
444
+ {
445
+ 'text': str, # Response text
446
+ 'tools_used': list, # Tools that were called
447
+ 'screenshots': list, # Paths to any screenshots taken
448
+ 'success': bool, # Whether execution succeeded
449
+ 'error': str|None # Error message if failed
450
+ }
451
+ """
452
+ if scope is None:
453
+ scope = scope_for("telegram", chat_id)
454
+ why = self._usable()
455
+ if why:
456
+ text = ("This chat bridge isn't available on a Hyper-deployed install (HyperSpeed V2 "
457
+ "only); use the HyperClaw chat instead." if why == "hyper_deployed" else
458
+ "This chat bridge has no model configured.")
459
+ return {'text': text, 'tools_used': [], 'screenshots': [], 'files': [],
460
+ 'success': False, 'error': why}
461
+ loop = asyncio.get_event_loop()
462
+ return await loop.run_in_executor(
463
+ _executor,
464
+ self._execute_sync,
465
+ message,
466
+ chat_id,
467
+ include_history,
468
+ normalize_scope(scope),
469
+ )
470
+
471
+
472
+ # Failover ladder: on overload/rate-limit/transient errors (and policy refusals), walk
473
+ # down the Claude 5 family instead of failing the turn. Chain is built from the env-driven
474
+ # tier config (FABLE_MODEL / HYPERCLAW_OPUS_MODEL / HYPERCLAW_SONNET_MODEL) so operator overrides in
475
+ # .env apply to failover too, not just the primary pick.
476
+ _FAILOVER_CHAIN = ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] # fallback if model_selector unavailable
477
+
478
+ def _failover_chain(self):
479
+ try:
480
+ from hyperclaw.model_selector import tiers
481
+ t = tiers()
482
+ chain = [t["fable"], t["opus"], t["sonnet"]]
483
+ # De-dup while preserving order (overrides may collapse tiers)
484
+ return list(dict.fromkeys(chain))
485
+ except Exception:
486
+ return list(self._FAILOVER_CHAIN)
487
+
488
+ def _create_with_failover(self, client, *, model, deadline=None, **kwargs):
489
+ """client.messages.create with model failover on 429/5xx/timeouts/refusals.
490
+
491
+ Returns (response, model_used). Raises the last transient error if every
492
+ rung fails; respects an optional time.monotonic() deadline between rungs.
493
+ """
494
+ import time as _time
495
+ import logging
496
+ log = logging.getLogger("tui_bridge")
497
+ chain = [model] + [m for m in self._failover_chain() if m != model]
498
+ last_err = None
499
+ last_refusal = None
500
+ for attempt, m in enumerate(chain):
501
+ if deadline is not None and _time.monotonic() > deadline and attempt > 0:
502
+ break # out of time budget; fall through to last_err/last_refusal
503
+ try:
504
+ kw = kwargs
505
+ if m != model and kwargs.get("max_tokens"):
506
+ # a failover rung keeps the turn's max unless its model needs less
507
+ from hyperclaw.cost_guard import max_output
508
+ kw = dict(kwargs, max_tokens=min(int(kwargs["max_tokens"]),
509
+ max_output("bridge", "anthropic", m)))
510
+ resp = client.messages.create(model=m, **kw)
511
+ if getattr(resp, "stop_reason", None) == "refusal":
512
+ sd = getattr(resp, "stop_details", None)
513
+ log.warning(
514
+ f"Refusal on {m} (category={getattr(sd, 'category', None)}): "
515
+ f"{getattr(sd, 'explanation', '')}"
516
+ )
517
+ last_refusal = (resp, m)
518
+ continue # try the next rung — categories often differ by model
519
+ if m != model:
520
+ log.warning(f"Model failover: {model} -> {m}")
521
+ return resp, m
522
+ except anthropic.APIStatusError as e:
523
+ status = getattr(e, "status_code", 0)
524
+ if status in (429, 500, 502, 503, 529):
525
+ last_err = e
526
+ _time.sleep(min(1 + attempt, 2))
527
+ continue
528
+ raise
529
+ except (anthropic.APIConnectionError, anthropic.APITimeoutError) as e:
530
+ last_err = e
531
+ _time.sleep(min(1 + attempt, 2))
532
+ continue
533
+ if last_refusal is not None:
534
+ return last_refusal # whole chain refused — surface it, don't crash
535
+ if last_err is not None:
536
+ raise last_err
537
+ raise RuntimeError("Model failover chain exhausted with no result")
538
+
539
+ def _execute_sync(
540
+ self,
541
+ message: str,
542
+ chat_id: int,
543
+ include_history: bool,
544
+ scope: str = GUEST,
545
+ ) -> Dict[str, Any]:
546
+ """Synchronous execution (runs in thread pool). One message is one
547
+ task for the cost guard (spec §9)."""
548
+ from hyperclaw import cost_guard, taint
549
+ scope = normalize_scope(scope)
550
+ notices = cost_guard.take_owner_notices() if scope == OWNER else []
551
+ session = f"{SURFACE}:{chat_id}"
552
+ if scope != OWNER and isinstance(message, str):
553
+ # A guest's words: connector content for the taint guard.
554
+ message = taint.mark(message, "telegram", "telegram", session=session)
555
+ with taint.conversation(session), \
556
+ cost_guard.task(surface=SURFACE, request=message, owner=(scope == OWNER),
557
+ conversation=session) as task:
558
+ result = self._execute_turn(message, chat_id, include_history, scope)
559
+ from hyperclaw import client_guard # what the restricted-client guard left out
560
+ withheld_note = client_guard.reply_note(task)
561
+ if withheld_note and result.get('text') is not None:
562
+ result['text'] = (result['text'].rstrip() + "\n\n" + withheld_note).strip()
563
+ if scope == OWNER:
564
+ line = cost_guard.spend_line(task)
565
+ extra = ([line] if line else []) + notices
566
+ if extra and result.get('text') is not None:
567
+ result['text'] = (result['text'].rstrip() + "\n\n" + "\n".join(extra)).strip()
568
+ return result
569
+
570
+ def _execute_turn(
571
+ self,
572
+ message: str,
573
+ chat_id: int,
574
+ include_history: bool,
575
+ scope: str = GUEST,
576
+ ) -> Dict[str, Any]:
577
+ from hyperclaw.cost_guard import (CostCapReached, current_task, guard_client, max_output,
578
+ stop_message)
579
+ from hyperclaw.client_guard import RestrictedContentBlocked
580
+
581
+ result = {
582
+ 'text': '',
583
+ 'tools_used': [],
584
+ 'screenshots': [],
585
+ 'files': [],
586
+ 'success': True,
587
+ 'error': None
588
+ }
589
+
590
+ # Bind this worker thread to the conversation so send_file(via='here')
591
+ # queues files for THIS chat; drained into result['files'] at turn end.
592
+ try:
593
+ from . import outbox as _outbox
594
+ except Exception:
595
+ _outbox = None
596
+ if _outbox is not None:
597
+ try:
598
+ _outbox.set_current_session(chat_id)
599
+ except Exception:
600
+ # Clear any stale binding from a previous turn on this pooled thread so
601
+ # files can't be queued into the WRONG chat; drain below still works.
602
+ try:
603
+ _outbox.set_current_session(None)
604
+ except Exception:
605
+ pass
606
+
607
+ try:
608
+ # timeout/max_retries: SDK defaults are 600s x 3 attempts, which multiplied by
609
+ # the 3-rung failover ladder and the 12-iteration loop is a multi-hour hang.
610
+ client = guard_client(anthropic.Anthropic(api_key=self.api_key, timeout=120.0,
611
+ max_retries=1), "anthropic")
612
+ import time as _walltime
613
+ _turn_deadline = _walltime.monotonic() + 600 # 10 min wall-clock per message
614
+
615
+ if scope == OWNER:
616
+ # Get relevant memories for this message
617
+ memory_context = self._get_relevant_memories(message)
618
+
619
+ # Build system prompt with memory context (fenced as DATA)
620
+ system_with_memory = self._owner_system_with_memories(memory_context)
621
+ else:
622
+ # Guest fence: no owner memory, recalls or persona in context.
623
+ system_with_memory = self.guest_system_prompt
624
+
625
+ # Build messages with history
626
+ messages = []
627
+ if include_history:
628
+ history = self.get_session_history(chat_id)
629
+ messages.extend(history[-20:]) # Last 10 exchanges
630
+
631
+ messages.append({"role": "user", "content": message})
632
+
633
+ # Bounded agentic loop. Interactive chat turns should resolve quickly; genuinely large
634
+ # jobs get delegated/queued, not spun inline. The guards below stop runaway loops
635
+ # (the previous version had max_iterations=100 and NO loop/error detection).
636
+ max_iterations = 12
637
+ iteration = 0
638
+ response_text = ""
639
+ # Route to a focused tool subset for this message (fail-safe to all tools).
640
+ selected_tools = self._select_tools(message)
641
+ if scope != OWNER:
642
+ selected_tools = guest_tools(self.tools)
643
+ offered_names = {t.get("name") for t in selected_tools}
644
+ # Model routing (Fable backend): pick the right model for THIS request.
645
+ try:
646
+ from hyperclaw.model_selector import pick_model
647
+ turn_model = pick_model(message, self.model)
648
+ except Exception:
649
+ turn_model = self.model
650
+ import hashlib as _hashlib, json as _json
651
+ _tool_sig_counts = {} # (tool, input) signature -> times called this turn
652
+ _consecutive_errors = 0
653
+ _MAX_REPEAT = 3
654
+ _loop_break = False
655
+ # Wave 2: idempotency per conversation (persists across turns and
656
+ # restarts), retry budgets per tool/resource, ~8 tool calls per message.
657
+ # Wave 3: the user's own words in this chat are the provenance a
658
+ # read-only fetch is checked against; ask-tier calls are queued
659
+ # for the owner's Send / Edit / Cancel (result['approvals']).
660
+ from hyperclaw.tools.approval import provenance_from_messages
661
+ guard = TurnGuard(conversation=f"{SURFACE}:{chat_id}", surface=SURFACE,
662
+ call_cap=chat_tool_call_cap(),
663
+ provenance=provenance_from_messages(messages))
664
+ result['approvals'] = guard.queued
665
+ _task = current_task()
666
+ if _task is not None and _task.evidence is None:
667
+ _task.evidence = guard # the stop message is built from its results
668
+
669
+ while iteration < max_iterations:
670
+ iteration += 1
671
+ if _walltime.monotonic() > _turn_deadline:
672
+ if not response_text.strip():
673
+ response_text = ("This is taking longer than my time budget for one message. "
674
+ "Tell me which part to prioritize and I'll continue.")
675
+ break
676
+
677
+ # Call Claude (with model failover on overload/transient errors/refusals).
678
+ # At a cost cap the guard refuses the call: stop and ask (owner) or
679
+ # a neutral reply with the owner notified (guest); never a cheaper model.
680
+ try:
681
+ response, _model_used = self._create_with_failover(
682
+ client,
683
+ model=turn_model,
684
+ deadline=_turn_deadline,
685
+ max_tokens=max_output("bridge", "anthropic", turn_model),
686
+ system=system_with_memory,
687
+ tools=selected_tools,
688
+ messages=messages
689
+ )
690
+ except CostCapReached as _cap:
691
+ response_text = (response_text.rstrip() + "\n\n"
692
+ + stop_message(_cap, owner=(scope == OWNER))).strip()
693
+ result['stopped_at_cost_cap'] = _cap.scope
694
+ break
695
+ except RestrictedContentBlocked as _blocked: # nothing was sent
696
+ response_text = (response_text.rstrip() + "\n\n"
697
+ + _blocked.user_message).strip()
698
+ break
699
+ result['model_used'] = _model_used
700
+ if _model_used != turn_model:
701
+ result['failover_from'] = turn_model
702
+ if getattr(response, "stop_reason", None) == "refusal":
703
+ sd = getattr(response, "stop_details", None)
704
+ response_text = response_text or (
705
+ "I can't help with that request"
706
+ + (f" ({sd.category})" if sd is not None and getattr(sd, 'category', None) else "")
707
+ + "."
708
+ )
709
+ break
710
+
711
+ # Process response
712
+ assistant_content = []
713
+ tool_results = []
714
+ has_text_this_turn = False
715
+
716
+ for block in response.content:
717
+ if block.type == "text":
718
+ text = block.text
719
+ if text.strip():
720
+ has_text_this_turn = True
721
+ response_text += text
722
+ assistant_content.append({
723
+ "type": "text",
724
+ "text": text
725
+ })
726
+
727
+ elif block.type == "tool_use":
728
+ tool_name = block.name
729
+ tool_input = block.input
730
+
731
+ # Track tool usage
732
+ result['tools_used'].append({
733
+ 'name': tool_name,
734
+ 'input': tool_input
735
+ })
736
+
737
+ # Loop guard: refuse to run the SAME tool with identical input repeatedly
738
+ # (the classic runaway shape). Legit multi-step work varies tool/input, so
739
+ # this only trips on stuck repetition - not on normal long tool sequences.
740
+ try:
741
+ _sig = _hashlib.sha1(
742
+ (tool_name + _json.dumps(tool_input, sort_keys=True, default=str)).encode()
743
+ ).hexdigest()
744
+ except Exception:
745
+ _sig = tool_name
746
+ _tool_sig_counts[_sig] = _tool_sig_counts.get(_sig, 0) + 1
747
+ if _tool_sig_counts[_sig] > _MAX_REPEAT:
748
+ envelope = make_envelope(
749
+ Status.BUDGET_EXHAUSTED,
750
+ f"[loop-guard] '{tool_name}' was already called with identical "
751
+ f"input {_MAX_REPEAT} times; it was NOT run again. Change approach "
752
+ f"or give your final answer.")
753
+ _loop_break = True
754
+ else:
755
+ if tool_name not in offered_names or (
756
+ scope != OWNER and tool_name not in GUEST_SAFE_TOOLS):
757
+ # Never run a tool that was not offered on this
758
+ # surface/scope, whatever the model asks for.
759
+ envelope = make_envelope(
760
+ Status.PERMISSION_DENIED,
761
+ f"Tool '{tool_name}' is not available in this chat; it was "
762
+ f"NOT run.", reason=REASON_SURFACE)
763
+ else:
764
+ envelope = self.dispatch_tool(tool_name, tool_input, scope=scope,
765
+ guard=guard)
766
+ tool_result = envelope.to_content(8000)
767
+ # Consecutive-error backstop
768
+ if not envelope.ok:
769
+ _consecutive_errors += 1
770
+ else:
771
+ _consecutive_errors = 0
772
+
773
+ assistant_content.append({
774
+ "type": "tool_use",
775
+ "id": block.id,
776
+ "name": tool_name,
777
+ "input": tool_input
778
+ })
779
+
780
+ tool_results.append({
781
+ "type": "tool_result",
782
+ "tool_use_id": block.id,
783
+ "content": tool_result
784
+ })
785
+
786
+ # Add to messages
787
+ messages.append({"role": "assistant", "content": assistant_content})
788
+
789
+ if tool_results:
790
+ messages.append({"role": "user", "content": tool_results})
791
+ if guard.cap_hit: # tool-call cap: stop and say what did not run
792
+ note = guard.stop_note()
793
+ response_text = (response_text.rstrip() + "\n\n" + note).strip()
794
+ result['stopped_at_tool_cap'] = True
795
+ break
796
+ # Stop if a loop or repeated tool errors were detected this turn.
797
+ if _loop_break or _consecutive_errors >= 5:
798
+ if not response_text.strip():
799
+ response_text = ("I stopped because I was repeating the same step without "
800
+ "making progress. Tell me how you'd like to proceed.")
801
+ break
802
+ # Otherwise keep working through the tool sequence.
803
+ else:
804
+ # No tools this turn. (assistant turn already appended above - do NOT re-append.)
805
+ if response.stop_reason == "max_tokens":
806
+ messages.append({"role": "user", "content": "Continue from where you left off."})
807
+ continue
808
+ # Finished naturally.
809
+ break
810
+
811
+ # If the step budget was exhausted without an answer, say so (don't return blank/hang).
812
+ if iteration >= max_iterations and not response_text.strip():
813
+ response_text = ("I hit my step limit for this turn without finishing. The task may "
814
+ "need to be broken down - tell me which part to tackle first.")
815
+
816
+ # Update session history
817
+ self.add_to_history(chat_id, "user", message)
818
+ self.add_to_history(chat_id, "assistant", response_text or assistant_content)
819
+
820
+ # Record in cross-channel memory (non-blocking). Owner only: a
821
+ # guest must not be able to write into the owner's memory.
822
+ if scope == OWNER:
823
+ try:
824
+ from .memory_bus import get_memory_bus
825
+ bus = get_memory_bus()
826
+ bus.record_exchange(
827
+ channel="tui_bridge",
828
+ user_message=message,
829
+ assistant_message=response_text or str(assistant_content)[:2000],
830
+ metadata={"chat_id": chat_id},
831
+ extract_knowledge=True
832
+ )
833
+ except Exception:
834
+ pass # Don't fail on memory errors
835
+
836
+ result['text'] = response_text
837
+
838
+ except Exception as e:
839
+ result['success'] = False
840
+ result['error'] = str(e)
841
+ # The technical error stays in result['error'] and the crash log
842
+ # below; the user-facing text names the failed action only.
843
+ result['text'] = failure_notice("request")
844
+ # Crash guard: a failed message must never take the process down and must
845
+ # leave forensics. Full traceback to a dedicated, private crash log.
846
+ try:
847
+ import traceback
848
+ crash_log = HYPERCLAW_ROOT / "logs" / "crashes.log"
849
+ crash_log.parent.mkdir(parents=True, exist_ok=True)
850
+ with open(crash_log, "a") as fh:
851
+ fh.write(f"\n=== {datetime.now().isoformat()} chat={chat_id} ===\n")
852
+ fh.write(f"message: {str(message)[:500]}\n")
853
+ traceback.print_exc(file=fh)
854
+ os.chmod(crash_log, 0o600)
855
+ except Exception:
856
+ pass
857
+
858
+ # Deliver any files tools queued for this conversation (send_file via='here',
859
+ # doc engines, charts). Channel adapters send these natively after the text.
860
+ try:
861
+ if _outbox is not None:
862
+ result['files'] = _outbox.drain(chat_id)
863
+ except Exception:
864
+ pass
865
+
866
+ return result
867
+
868
+ async def execute_gmail(self, action: str, **kwargs) -> Dict[str, Any]:
869
+ """
870
+ Execute Gmail-specific operations.
871
+
872
+ Actions:
873
+ - inbox: List inbox emails
874
+ - read: Read specific email
875
+ - send: Send email
876
+ - search: Search emails
877
+ - thread: Get email thread
878
+ """
879
+ try:
880
+ from .integrations_layer import (
881
+ gmail_list_inbox, gmail_get_message, gmail_send,
882
+ gmail_search, gmail_get_thread, gmail_read_message_text
883
+ )
884
+
885
+ if action == "inbox":
886
+ max_results = kwargs.get('max_results', 10)
887
+ emails = await asyncio.to_thread(gmail_list_inbox, max_results)
888
+ return {'success': True, 'data': emails}
889
+
890
+ elif action == "read":
891
+ message_id = kwargs.get('message_id')
892
+ if not message_id:
893
+ return {'success': False, 'error': 'message_id required'}
894
+ text = await asyncio.to_thread(gmail_read_message_text, message_id)
895
+ return {'success': True, 'data': text}
896
+
897
+ elif action == "send":
898
+ to = kwargs.get('to')
899
+ subject = kwargs.get('subject')
900
+ body = kwargs.get('body')
901
+ cc = kwargs.get('cc', '')
902
+ reply_to = kwargs.get('reply_to_id', '')
903
+
904
+ if not all([to, subject, body]):
905
+ return {'success': False, 'error': 'to, subject, and body required'}
906
+
907
+ result = await asyncio.to_thread(gmail_send, to, subject, body, cc, reply_to)
908
+ return {'success': True, 'data': result}
909
+
910
+ elif action == "search":
911
+ query = kwargs.get('query', '')
912
+ max_results = kwargs.get('max_results', 10)
913
+ results = await asyncio.to_thread(gmail_search, query, max_results)
914
+ return {'success': True, 'data': results}
915
+
916
+ elif action == "thread":
917
+ thread_id = kwargs.get('thread_id')
918
+ if not thread_id:
919
+ return {'success': False, 'error': 'thread_id required'}
920
+ thread = await asyncio.to_thread(gmail_get_thread, thread_id)
921
+ return {'success': True, 'data': thread}
922
+
923
+ else:
924
+ return {'success': False, 'error': f'Unknown action: {action}'}
925
+
926
+ except Exception as e:
927
+ return {'success': False, 'error': str(e)}
928
+
929
+
930
+ # Singleton instance
931
+ _bridge_instance: Optional[TUIBridge] = None
932
+
933
+ def get_tui_bridge() -> TUIBridge:
934
+ """Get singleton TUI bridge instance."""
935
+ global _bridge_instance
936
+ if _bridge_instance is None:
937
+ _bridge_instance = TUIBridge()
938
+ return _bridge_instance