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
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Model catalog and model switching (docs/MODEL-SWITCHING.md).
|
|
2
|
+
|
|
3
|
+
- The Hyper model list comes from the gateway (``GET {gateway}/v1/catalog``
|
|
4
|
+
with the install key), cached with a TTL; offline it falls back to the last
|
|
5
|
+
good cache, then to the packaged HyperSpeed V2 entry. New Hyper models
|
|
6
|
+
appear without an app update.
|
|
7
|
+
- Third-party providers are merged here, client-side, from models.yaml —
|
|
8
|
+
never from the gateway — and labelled "your own provider (<name>)", or
|
|
9
|
+
"<model>, via Hyper" on a Hyper-issued key (hyperclaw.model_policy). A
|
|
10
|
+
Hyper-deployed install lists only the models on its allowed list
|
|
11
|
+
(hyperclaw.managed_config; default HyperSpeed V2 only), and choosing a
|
|
12
|
+
third-party model needs the owner's consent (docs/MODEL-POLICY.md).
|
|
13
|
+
- tools vs chat-only comes ONLY from signed qualification records
|
|
14
|
+
(``Provider.effective_capabilities``). The catalog never grants tools.
|
|
15
|
+
- A selection is per surface ("terminal", "telegram") and lasts for the
|
|
16
|
+
running process. ``selected_route`` is the hook the route selection uses.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Callable, Optional
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("hyperclaw.model_catalog")
|
|
31
|
+
|
|
32
|
+
TERMINAL = "terminal"
|
|
33
|
+
TELEGRAM = "telegram"
|
|
34
|
+
|
|
35
|
+
DEFAULT_TTL_S = 6 * 3600
|
|
36
|
+
FETCH_TIMEOUT_S = 5.0
|
|
37
|
+
CATALOG_URL_ENV = "HYPERCLAW_CATALOG_URL"
|
|
38
|
+
TTL_ENV = "HYPERCLAW_CATALOG_TTL_S"
|
|
39
|
+
|
|
40
|
+
SOURCE_GATEWAY = "Hyper gateway"
|
|
41
|
+
SOURCE_CACHE = "cached"
|
|
42
|
+
SOURCE_OFFLINE = "offline, last good copy"
|
|
43
|
+
SOURCE_PACKAGED = "built-in default"
|
|
44
|
+
|
|
45
|
+
# Ctrl-T cycles models in the terminal (docs/MODEL-SWITCHING.md).
|
|
46
|
+
# - libedit (macOS's Python): ^T calls readline's completion hook, which runs
|
|
47
|
+
# the switch in place and redraws the line; what was typed is kept. (A
|
|
48
|
+
# libedit string macro only replays its first key under Python's
|
|
49
|
+
# callback-driven input(), so a "/model next" macro cannot work there.)
|
|
50
|
+
# - GNU readline: a macro that clears the line and submits "/model next"
|
|
51
|
+
# (so a half-typed message is never sent).
|
|
52
|
+
SHORTCUT_LABEL = "Ctrl-T"
|
|
53
|
+
SHORTCUT_COMMAND = "/model next"
|
|
54
|
+
SHORTCUT_RAW = "\x14" # what input() returns for Ctrl-T without readline
|
|
55
|
+
_GNU_BINDING = r'"\C-t": "\C-a\C-k/model next\n"'
|
|
56
|
+
_LIBEDIT_BINDINGS = ("bind ^T rl_complete", "bind ^I ed-insert")
|
|
57
|
+
|
|
58
|
+
_ID = re.compile(r"^[A-Za-z0-9._:/-]{1,128}$")
|
|
59
|
+
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
|
|
60
|
+
|
|
61
|
+
STATUS_TOOLS = "tools (qualified)"
|
|
62
|
+
STATUS_NOT_QUALIFIED = "chat-only (not qualified for tools)"
|
|
63
|
+
STATUS_CHAT = "chat-only"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ───────────────────────── catalog ─────────────────────────
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CatalogResult:
|
|
70
|
+
models: list # sanitized entries (dicts)
|
|
71
|
+
source: str # SOURCE_*
|
|
72
|
+
fetched_at: Optional[float] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ttl_s() -> float:
|
|
76
|
+
try:
|
|
77
|
+
return max(0.0, float(os.environ.get(TTL_ENV, "") or DEFAULT_TTL_S))
|
|
78
|
+
except ValueError:
|
|
79
|
+
return float(DEFAULT_TTL_S)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cache_path() -> Path:
|
|
83
|
+
from hyperclaw.userconfig import root
|
|
84
|
+
return root() / "cache" / "model_catalog.json"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def packaged_default() -> list:
|
|
88
|
+
"""The one entry every install has: HyperSpeed V2 as configured."""
|
|
89
|
+
from hyperclaw import hyperspeed
|
|
90
|
+
return [{"id": hyperspeed.model(), "display_name": hyperspeed.LABEL, "family": "hyper",
|
|
91
|
+
"modalities": ["text"], "context_window": None, "default": True,
|
|
92
|
+
"deprecated": False}]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def catalog_url() -> str:
|
|
96
|
+
"""HYPERCLAW_CATALOG_URL, else the HyperSpeed base + /catalog. A raw RunPod
|
|
97
|
+
base has no catalog route, so it is never asked ("" = don't fetch)."""
|
|
98
|
+
explicit = os.environ.get(CATALOG_URL_ENV, "").strip()
|
|
99
|
+
if explicit:
|
|
100
|
+
return explicit
|
|
101
|
+
from hyperclaw import hyperspeed
|
|
102
|
+
base = hyperspeed.base_url().rstrip("/")
|
|
103
|
+
if not base or "runpod" in base.lower():
|
|
104
|
+
return ""
|
|
105
|
+
return base + "/catalog" if base.endswith("/v1") else base + "/v1/catalog"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _key_fp(key: str) -> str:
|
|
109
|
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:12] if key else ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def sanitize(payload) -> Optional[list]:
|
|
113
|
+
"""Valid entries from a catalog response, or None if it isn't one.
|
|
114
|
+
Unknown fields (anything about tools/capabilities included) are dropped."""
|
|
115
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
|
|
116
|
+
return None
|
|
117
|
+
out, seen = [], set()
|
|
118
|
+
for m in payload["models"]:
|
|
119
|
+
if not isinstance(m, dict):
|
|
120
|
+
continue
|
|
121
|
+
mid = m.get("id")
|
|
122
|
+
if not isinstance(mid, str) or not _ID.match(mid) or mid in seen:
|
|
123
|
+
continue
|
|
124
|
+
seen.add(mid)
|
|
125
|
+
name = m.get("display_name")
|
|
126
|
+
name = _CTRL.sub("", name)[:80].strip() if isinstance(name, str) else ""
|
|
127
|
+
fam = m.get("family")
|
|
128
|
+
mods = m.get("modalities")
|
|
129
|
+
cw = m.get("context_window")
|
|
130
|
+
out.append({
|
|
131
|
+
"id": mid,
|
|
132
|
+
"display_name": name or mid,
|
|
133
|
+
"family": _CTRL.sub("", fam)[:40] if isinstance(fam, str) and fam else "hyper",
|
|
134
|
+
"modalities": [x for x in mods if isinstance(x, str)][:8]
|
|
135
|
+
if isinstance(mods, list) else ["text"],
|
|
136
|
+
"context_window": cw if isinstance(cw, int) and not isinstance(cw, bool)
|
|
137
|
+
and cw > 0 else None,
|
|
138
|
+
"default": m.get("default") is True,
|
|
139
|
+
"deprecated": m.get("deprecated") is True,
|
|
140
|
+
})
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _http_fetch(url: str, key: str, timeout: float):
|
|
145
|
+
import httpx
|
|
146
|
+
with httpx.Client(timeout=timeout) as client:
|
|
147
|
+
r = client.get(url, headers={"Authorization": f"Bearer {key}",
|
|
148
|
+
"Accept": "application/json"})
|
|
149
|
+
return r.status_code, (r.json() if r.status_code == 200 else None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_cache(url: str, key: str) -> Optional[dict]:
|
|
153
|
+
try:
|
|
154
|
+
data = json.loads(cache_path().read_text(encoding="utf-8"))
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|
|
157
|
+
if not isinstance(data, dict) or data.get("url") != url or data.get("key_fp") != _key_fp(key):
|
|
158
|
+
return None
|
|
159
|
+
models = sanitize({"models": data.get("models")})
|
|
160
|
+
if models is None or not isinstance(data.get("fetched_at"), (int, float)):
|
|
161
|
+
return None
|
|
162
|
+
return {"models": models, "fetched_at": float(data["fetched_at"])}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _write_cache(url: str, key: str, models: list, now: float) -> None:
|
|
166
|
+
p = cache_path()
|
|
167
|
+
try:
|
|
168
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
tmp = p.with_suffix(".tmp")
|
|
170
|
+
tmp.write_text(json.dumps({"url": url, "key_fp": _key_fp(key), "fetched_at": now,
|
|
171
|
+
"models": models}, indent=1), encoding="utf-8")
|
|
172
|
+
os.chmod(tmp, 0o600)
|
|
173
|
+
os.replace(tmp, p)
|
|
174
|
+
except OSError as e:
|
|
175
|
+
log.warning(f"model catalog cache not written: {e}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_catalog(fetch: Optional[Callable] = None, now: Optional[float] = None,
|
|
179
|
+
force: bool = False) -> CatalogResult:
|
|
180
|
+
"""Fresh cache → gateway → last good cache → packaged default."""
|
|
181
|
+
from hyperclaw import hyperspeed
|
|
182
|
+
now = time.time() if now is None else now
|
|
183
|
+
url = catalog_url()
|
|
184
|
+
key = os.environ.get(hyperspeed.API_KEY_ENV, "").strip()
|
|
185
|
+
if not url or not key:
|
|
186
|
+
return CatalogResult(packaged_default(), SOURCE_PACKAGED)
|
|
187
|
+
cached = _read_cache(url, key)
|
|
188
|
+
if cached and not force and now - cached["fetched_at"] < ttl_s():
|
|
189
|
+
return CatalogResult(cached["models"], SOURCE_CACHE, cached["fetched_at"])
|
|
190
|
+
try:
|
|
191
|
+
status, payload = (fetch or _http_fetch)(url, key, FETCH_TIMEOUT_S)
|
|
192
|
+
models = sanitize(payload) if status == 200 else None
|
|
193
|
+
if models is None:
|
|
194
|
+
raise ValueError(f"catalog answered HTTP {status}" if status != 200
|
|
195
|
+
else "catalog response is malformed")
|
|
196
|
+
_write_cache(url, key, models, now)
|
|
197
|
+
return CatalogResult(models, SOURCE_GATEWAY, now)
|
|
198
|
+
except Exception as e: # never fatal: the list falls back, chat is unaffected
|
|
199
|
+
log.warning(f"model catalog unavailable ({type(e).__name__}: {str(e)[:120]})")
|
|
200
|
+
if cached:
|
|
201
|
+
return CatalogResult(cached["models"], SOURCE_OFFLINE, cached["fetched_at"])
|
|
202
|
+
return CatalogResult(packaged_default(), SOURCE_PACKAGED)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ───────────────────────── listing ─────────────────────────
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class ModelOption:
|
|
209
|
+
provider: str
|
|
210
|
+
model: str
|
|
211
|
+
display_name: str
|
|
212
|
+
source: str # "HyperSpeed V2" | "your own provider (Anthropic)"
|
|
213
|
+
origin: str # hyper | customer
|
|
214
|
+
family: str
|
|
215
|
+
tools: bool # from the signed record only
|
|
216
|
+
status: str
|
|
217
|
+
default: bool = False
|
|
218
|
+
deprecated: bool = False
|
|
219
|
+
context_window: Optional[int] = None
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def key(self) -> str:
|
|
223
|
+
return f"{self.provider}/{self.model}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def tool_status(prov, model: str) -> tuple:
|
|
227
|
+
"""(tools?, label) from the signed record via effective_capabilities."""
|
|
228
|
+
if "tool_use" in prov.effective_capabilities(model):
|
|
229
|
+
return True, STATUS_TOOLS
|
|
230
|
+
if "tool_use" in prov.capabilities:
|
|
231
|
+
return False, STATUS_NOT_QUALIFIED
|
|
232
|
+
return False, STATUS_CHAT
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _customer_models(prov) -> list:
|
|
236
|
+
seen, out = set(), []
|
|
237
|
+
for m in [prov.model_for()] + list((prov.models or {}).values()):
|
|
238
|
+
if m and isinstance(m, str) and m not in seen:
|
|
239
|
+
seen.add(m)
|
|
240
|
+
out.append(m)
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def available_models(registry=None, catalog: Optional[CatalogResult] = None) -> list:
|
|
245
|
+
"""Every model this install can use now: Hyper models first (catalog
|
|
246
|
+
order, plus the configured V2 model), then third-party providers
|
|
247
|
+
(Claude, OpenAI, Grok, ...). Hyper-deployed: only the models on the
|
|
248
|
+
install's allowed list (hyperclaw.managed_config; default V2 only)."""
|
|
249
|
+
from hyperclaw import hyperspeed
|
|
250
|
+
from hyperclaw.deployment import is_hyper
|
|
251
|
+
from hyperclaw.managed_config import allowed_for, is_allowed
|
|
252
|
+
from hyperclaw.model_policy import model_label
|
|
253
|
+
if registry is None:
|
|
254
|
+
from hyperclaw.providers import registry as _reg
|
|
255
|
+
registry = _reg()
|
|
256
|
+
hyper_only = is_hyper()
|
|
257
|
+
out: list = []
|
|
258
|
+
|
|
259
|
+
hp = registry.providers.get(hyperspeed.PROVIDER_NAME)
|
|
260
|
+
if hp is not None and hp.live and hp.origin == "hyper":
|
|
261
|
+
catalog = catalog or get_catalog()
|
|
262
|
+
entries = [e for e in catalog.models
|
|
263
|
+
if not hyper_only or e.get("family", "hyper") == "hyper"]
|
|
264
|
+
configured = hp.model_for()
|
|
265
|
+
if configured and all(e["id"] != configured for e in entries):
|
|
266
|
+
entries = entries + [{"id": configured, "display_name": hp.display_label,
|
|
267
|
+
"family": "hyper", "context_window": None,
|
|
268
|
+
"default": not any(e.get("default") for e in entries),
|
|
269
|
+
"deprecated": False}]
|
|
270
|
+
for e in entries:
|
|
271
|
+
if not is_allowed(hp.name, e["id"]):
|
|
272
|
+
continue
|
|
273
|
+
tools, status = tool_status(hp, e["id"])
|
|
274
|
+
out.append(ModelOption(hp.name, e["id"], e.get("display_name") or e["id"],
|
|
275
|
+
hp.display_label, "hyper", e.get("family") or "hyper",
|
|
276
|
+
tools, status, bool(e.get("default")),
|
|
277
|
+
bool(e.get("deprecated")), e.get("context_window")))
|
|
278
|
+
|
|
279
|
+
for name, prov in registry.providers.items():
|
|
280
|
+
if prov.origin == "hyper" or not prov.live or "chat" not in prov.capabilities:
|
|
281
|
+
continue
|
|
282
|
+
models = _customer_models(prov)
|
|
283
|
+
if hyper_only:
|
|
284
|
+
explicit = [m for m in allowed_for(name) if m != "*"]
|
|
285
|
+
models = list(dict.fromkeys(
|
|
286
|
+
[m for m in models if is_allowed(name, m)] + explicit))
|
|
287
|
+
for m in models:
|
|
288
|
+
tools, status = tool_status(prov, m)
|
|
289
|
+
out.append(ModelOption(name, m, m, model_label(prov, m), "customer",
|
|
290
|
+
prov.name, tools, status))
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def answering_line(options: list, active_key: str) -> str:
|
|
295
|
+
"""'Answering now: <label> · model <id>' for the active model, '' if none."""
|
|
296
|
+
for o in options:
|
|
297
|
+
if o.key == active_key:
|
|
298
|
+
return f"Answering now: {o.source} · model {o.model}"
|
|
299
|
+
return ""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def format_listing(options: list, active_key: str, catalog: Optional[CatalogResult] = None,
|
|
303
|
+
surface: str = TERMINAL) -> str:
|
|
304
|
+
if not options:
|
|
305
|
+
return "No models are available on this install. Run `hyperclaw doctor`."
|
|
306
|
+
head = "Models on this install"
|
|
307
|
+
if catalog is not None:
|
|
308
|
+
when = (time.strftime(" %Y-%m-%d %H:%M", time.localtime(catalog.fetched_at))
|
|
309
|
+
if catalog.fetched_at else "")
|
|
310
|
+
head += f" (Hyper catalog: {catalog.source}{when})"
|
|
311
|
+
lines = [head + ":"]
|
|
312
|
+
for i, o in enumerate(options, 1):
|
|
313
|
+
name = o.display_name if o.display_name == o.model else f"{o.display_name} ({o.model})"
|
|
314
|
+
mark = "*" if o.key == active_key else " "
|
|
315
|
+
extra = " · deprecated" if o.deprecated else ""
|
|
316
|
+
tail = " ← active" if o.key == active_key else ""
|
|
317
|
+
lines.append(f"{mark} {i}. {name} · {o.source} · {o.status}{extra}{tail}")
|
|
318
|
+
answering = answering_line(options, active_key)
|
|
319
|
+
if answering:
|
|
320
|
+
lines.append(answering)
|
|
321
|
+
from hyperclaw.deployment import is_hyper
|
|
322
|
+
if is_hyper():
|
|
323
|
+
lines.append("Only the models on this install's allowed list can be selected.")
|
|
324
|
+
if surface == TELEGRAM:
|
|
325
|
+
lines.append("Switch: /model <number or id> · /model next · /model default. "
|
|
326
|
+
"Telegram replies are text only, whatever the model.")
|
|
327
|
+
else:
|
|
328
|
+
lines.append(f"Switch: /model <number or id> · {SHORTCUT_LABEL} or /model next · "
|
|
329
|
+
"/model default")
|
|
330
|
+
return "\n".join(lines)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def find_option(options: list, arg: str) -> Optional[ModelOption]:
|
|
334
|
+
arg = (arg or "").strip()
|
|
335
|
+
if not arg:
|
|
336
|
+
return None
|
|
337
|
+
if arg.isdigit():
|
|
338
|
+
i = int(arg)
|
|
339
|
+
return options[i - 1] if 1 <= i <= len(options) else None
|
|
340
|
+
for o in options:
|
|
341
|
+
if o.key == arg:
|
|
342
|
+
return o
|
|
343
|
+
hits = [o for o in options if o.model == arg] or \
|
|
344
|
+
[o for o in options if o.display_name.lower() == arg.lower()]
|
|
345
|
+
return hits[0] if len(hits) == 1 else None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def next_option(options: list, active_key: str) -> Optional[ModelOption]:
|
|
349
|
+
"""The model after the active one, skipping deprecated ones, wrapping."""
|
|
350
|
+
usable = [o for o in options if not o.deprecated] or list(options)
|
|
351
|
+
if not usable:
|
|
352
|
+
return None
|
|
353
|
+
keys = [o.key for o in usable]
|
|
354
|
+
if active_key in keys:
|
|
355
|
+
return usable[(keys.index(active_key) + 1) % len(usable)]
|
|
356
|
+
return usable[0]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ───────────────────────── selection (route hook) ─────────────────────────
|
|
360
|
+
|
|
361
|
+
_SELECTION: dict = {} # surface -> (provider name, model id)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def select(surface: str, provider: str, model: str) -> None:
|
|
365
|
+
_SELECTION[surface] = (provider, model)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def clear(surface: Optional[str] = None) -> None:
|
|
369
|
+
if surface is None:
|
|
370
|
+
_SELECTION.clear()
|
|
371
|
+
else:
|
|
372
|
+
_SELECTION.pop(surface, None)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def selection(surface: str) -> Optional[tuple]:
|
|
376
|
+
return _SELECTION.get(surface)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def is_selected(surface: str, provider: str, model: str) -> bool:
|
|
380
|
+
return _SELECTION.get(surface) == (provider, model)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def selected_route(registry, surface: str) -> Optional[tuple]:
|
|
384
|
+
"""(Provider, model) for this surface's selection, or None (no selection,
|
|
385
|
+
or it is no longer usable: provider gone or not live, not on a
|
|
386
|
+
Hyper-deployed install's allowed list, or a third-party provider without
|
|
387
|
+
the owner's consent)."""
|
|
388
|
+
sel = _SELECTION.get(surface)
|
|
389
|
+
if not sel:
|
|
390
|
+
return None
|
|
391
|
+
from hyperclaw.managed_config import is_allowed
|
|
392
|
+
from hyperclaw.model_policy import needs_consent
|
|
393
|
+
prov = getattr(registry, "providers", {}).get(sel[0])
|
|
394
|
+
if prov is None or not prov.live or "chat" not in prov.capabilities:
|
|
395
|
+
return None
|
|
396
|
+
if not is_allowed(prov.name, sel[1]) or needs_consent(prov):
|
|
397
|
+
return None
|
|
398
|
+
return prov, sel[1]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def prefer_selected(registry, surface: str, candidates: list, need=()) -> list:
|
|
402
|
+
"""Put the surface's selected model first in a slot ladder when it can
|
|
403
|
+
serve the turn (declared + qualified capabilities); else the ladder."""
|
|
404
|
+
sel = selected_route(registry, surface)
|
|
405
|
+
if not sel:
|
|
406
|
+
return list(candidates)
|
|
407
|
+
prov, model = sel
|
|
408
|
+
if not frozenset(need or ()) <= prov.effective_capabilities(model):
|
|
409
|
+
return list(candidates)
|
|
410
|
+
return [(prov, model)] + [(p, m) for p, m in candidates
|
|
411
|
+
if (p.name, m) != (prov.name, model)]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def route_key(route) -> str:
|
|
415
|
+
"""'provider/model' for a (mode, provider, model) route, '' if none."""
|
|
416
|
+
if not route or route[1] is None:
|
|
417
|
+
return ""
|
|
418
|
+
return f"{getattr(route[1], 'name', '?')}/{route[2] or ''}"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ───────────────────────── /model command ─────────────────────────
|
|
422
|
+
|
|
423
|
+
@dataclass
|
|
424
|
+
class CommandResult:
|
|
425
|
+
text: str
|
|
426
|
+
switched: Optional[ModelOption] = None
|
|
427
|
+
cleared: bool = False
|
|
428
|
+
needs_consent: bool = False
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
CONSENT_WORDS = ("yes", "agree")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def run_command(args: list, surface: str, active_key: str, registry=None,
|
|
435
|
+
catalog: Optional[CatalogResult] = None,
|
|
436
|
+
confirm: Optional[Callable] = None) -> CommandResult:
|
|
437
|
+
"""/model, /model next, /model default, /model <number|id|provider/id>.
|
|
438
|
+
Selection only; the caller re-applies the route, prompt and log.
|
|
439
|
+
|
|
440
|
+
A third-party model shows the one consent line (model_policy). Without
|
|
441
|
+
recorded consent the owner must agree: ``confirm(line) -> bool`` (the
|
|
442
|
+
terminal asks), or a trailing "yes" (``/model 3 yes`` on Telegram). No
|
|
443
|
+
consent, no switch."""
|
|
444
|
+
from hyperclaw import model_policy as mp
|
|
445
|
+
catalog = catalog or get_catalog()
|
|
446
|
+
options = available_models(registry, catalog)
|
|
447
|
+
if not args:
|
|
448
|
+
return CommandResult(format_listing(options, active_key, catalog, surface))
|
|
449
|
+
agreed = len(args) > 1 and args[-1].strip().lower() in CONSENT_WORDS
|
|
450
|
+
if agreed:
|
|
451
|
+
args = args[:-1]
|
|
452
|
+
arg = " ".join(args).strip()
|
|
453
|
+
if arg.lower() in ("default", "reset"):
|
|
454
|
+
clear(surface)
|
|
455
|
+
return CommandResult("Back to this install's default model.", cleared=True)
|
|
456
|
+
choice = next_option(options, active_key) if arg.lower() == "next" \
|
|
457
|
+
else find_option(options, arg)
|
|
458
|
+
if choice is None:
|
|
459
|
+
return CommandResult(f"No model {arg!r} on this install.\n"
|
|
460
|
+
+ format_listing(options, active_key, catalog, surface))
|
|
461
|
+
if choice.key == active_key:
|
|
462
|
+
return CommandResult(f"Already using {choice.display_name}.")
|
|
463
|
+
consent = ""
|
|
464
|
+
if choice.origin != "hyper":
|
|
465
|
+
consent = mp.consent_line(choice.provider)
|
|
466
|
+
if not mp.has_consent(choice.provider):
|
|
467
|
+
ok = agreed or (confirm is not None and bool(confirm(consent)))
|
|
468
|
+
if not ok:
|
|
469
|
+
how = (f"To go ahead, send /model {choice.key} yes" if confirm is None
|
|
470
|
+
else "Nothing changed.")
|
|
471
|
+
return CommandResult(f"{consent}\n{how}", needs_consent=True)
|
|
472
|
+
mp.record_consent(choice.provider, choice.model)
|
|
473
|
+
consent = "" # already shown by confirm, or just agreed to
|
|
474
|
+
select(surface, choice.provider, choice.model)
|
|
475
|
+
return CommandResult((consent + "\n" if consent else "")
|
|
476
|
+
+ f"Switched to {choice.display_name} · {choice.source} · "
|
|
477
|
+
f"{choice.status}.", switched=choice)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def record_switch(surface: str, old_key: str, new_key: str, mode: str,
|
|
481
|
+
kept: int) -> None:
|
|
482
|
+
"""Activity log entry (spec rule 6) for a model switch."""
|
|
483
|
+
try:
|
|
484
|
+
from hyperclaw.tools.ledger import get_ledger
|
|
485
|
+
get_ledger().record(conversation=surface, turn="", surface=surface,
|
|
486
|
+
tool="model_switch", effect="config",
|
|
487
|
+
target=f"{old_key or '(none)'} -> {new_key} ({mode})",
|
|
488
|
+
status="ok",
|
|
489
|
+
message=f"history kept ({kept} messages); mode {mode}")
|
|
490
|
+
except Exception as e:
|
|
491
|
+
log.warning(f"model switch not written to the activity log: {e}")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ───────────────────────── history for a chat-only model ─────────────────────────
|
|
495
|
+
|
|
496
|
+
def chat_only_messages(history: list) -> list:
|
|
497
|
+
"""History for a chat-only model on the Messages API: tool calls and
|
|
498
|
+
results flattened to labelled text (truth_guard.history_to_openai
|
|
499
|
+
chat-only path), same-role neighbours merged, no tool block types."""
|
|
500
|
+
from hyperclaw.truth_guard import history_to_openai
|
|
501
|
+
out: list = []
|
|
502
|
+
for m in history_to_openai(history, native_tools=False):
|
|
503
|
+
role = "assistant" if m.get("role") == "assistant" else "user"
|
|
504
|
+
text = m.get("content") or ""
|
|
505
|
+
if not isinstance(text, str) or not text.strip():
|
|
506
|
+
continue
|
|
507
|
+
if out and out[-1]["role"] == role:
|
|
508
|
+
out[-1]["content"] += "\n\n" + text
|
|
509
|
+
else:
|
|
510
|
+
out.append({"role": role, "content": text})
|
|
511
|
+
while out and out[0]["role"] != "user":
|
|
512
|
+
out.pop(0)
|
|
513
|
+
return out
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ───────────────────────── terminal shortcut ─────────────────────────
|
|
517
|
+
|
|
518
|
+
def shortcut_bindings(libedit: bool) -> tuple:
|
|
519
|
+
return _LIBEDIT_BINDINGS if libedit else (_GNU_BINDING,)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def install_shortcut(on_shortcut: Optional[Callable] = None, readline_mod=None) -> bool:
|
|
523
|
+
"""Bind Ctrl-T for input(). libedit needs ``on_shortcut`` (run in place);
|
|
524
|
+
GNU readline submits '/model next'. False if not installed."""
|
|
525
|
+
try:
|
|
526
|
+
rl = readline_mod
|
|
527
|
+
if rl is None:
|
|
528
|
+
import readline as rl # noqa: F811
|
|
529
|
+
libedit = "libedit" in (getattr(rl, "__doc__", "") or "") or \
|
|
530
|
+
getattr(rl, "backend", "") == "editline"
|
|
531
|
+
if libedit:
|
|
532
|
+
if on_shortcut is None:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
def _hook(text, state):
|
|
536
|
+
if state == 0:
|
|
537
|
+
try:
|
|
538
|
+
on_shortcut()
|
|
539
|
+
except Exception as e: # never break the input line
|
|
540
|
+
print(f"\n[model switch failed: {e}]")
|
|
541
|
+
try:
|
|
542
|
+
rl.redisplay()
|
|
543
|
+
except Exception:
|
|
544
|
+
pass
|
|
545
|
+
return None
|
|
546
|
+
rl.set_completer(_hook)
|
|
547
|
+
for b in shortcut_bindings(libedit):
|
|
548
|
+
rl.parse_and_bind(b)
|
|
549
|
+
return True
|
|
550
|
+
except Exception:
|
|
551
|
+
return False
|