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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
hyperclaw/tui_bridge.py
ADDED
|
@@ -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
|