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,591 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HyperClaw Orchestrator
|
|
3
|
+
Central coordinator for all agents, integrations, memory, and model routing.
|
|
4
|
+
Production-ready with cost-optimized model selection and multi-agent coordination.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, AsyncIterator, Optional, Union
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
|
|
17
|
+
from .memory_manager import MemoryManager, get_memory_manager
|
|
18
|
+
from .model_router import ModelRouter, ModelTier, get_model_router
|
|
19
|
+
from .agent_coordinator import AgentCoordinator, get_coordinator, Task
|
|
20
|
+
from .cost_guard import CostCapReached, stop_message
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("hyperclaw.orchestrator")
|
|
23
|
+
|
|
24
|
+
# Load environment
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
# Configuration
|
|
28
|
+
HYPERCLAW_ROOT = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Orchestrator:
|
|
32
|
+
"""
|
|
33
|
+
Central orchestrator for HyperClaw.
|
|
34
|
+
Manages agents, integrations, memory, and cost-optimized model routing.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._initialized = False
|
|
39
|
+
self._model_router: Optional[ModelRouter] = None
|
|
40
|
+
self._coordinator: Optional[AgentCoordinator] = None
|
|
41
|
+
self._memory: Optional[MemoryManager] = None
|
|
42
|
+
self._db_pool = None
|
|
43
|
+
self._integrations: dict[str, Any] = {}
|
|
44
|
+
self._system_prompt: str = ""
|
|
45
|
+
|
|
46
|
+
async def initialize(self, db_pool=None):
|
|
47
|
+
"""Initialize all components."""
|
|
48
|
+
if self._initialized:
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
logger.info("Initializing HyperClaw Orchestrator...")
|
|
52
|
+
|
|
53
|
+
# Database pool
|
|
54
|
+
self._db_pool = db_pool
|
|
55
|
+
|
|
56
|
+
# Initialize model router (handles Claude, ChatJimmy, etc.)
|
|
57
|
+
self._model_router = get_model_router()
|
|
58
|
+
logger.info(f"Model router initialized with {len(self._model_router.models)} models")
|
|
59
|
+
|
|
60
|
+
# Initialize agent coordinator
|
|
61
|
+
self._coordinator = await get_coordinator()
|
|
62
|
+
logger.info(f"Agent coordinator initialized with {len(self._coordinator.agents)} agents")
|
|
63
|
+
|
|
64
|
+
# Initialize memory manager
|
|
65
|
+
self._memory = await get_memory_manager(db_pool)
|
|
66
|
+
logger.info("Memory manager initialized")
|
|
67
|
+
|
|
68
|
+
# Load system prompt
|
|
69
|
+
self._system_prompt = self._build_system_prompt()
|
|
70
|
+
|
|
71
|
+
# Initialize integrations
|
|
72
|
+
await self._init_integrations()
|
|
73
|
+
|
|
74
|
+
# Start background workers for task processing
|
|
75
|
+
await self._coordinator.start_workers(num_workers=3)
|
|
76
|
+
|
|
77
|
+
self._initialized = True
|
|
78
|
+
logger.info("Orchestrator initialization complete")
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _build_guest_system_prompt() -> str:
|
|
82
|
+
"""Prompt for a non-owner chat: no workspace context, no memory."""
|
|
83
|
+
from .chat_scope import GUEST_RULES
|
|
84
|
+
return "\n".join([
|
|
85
|
+
"# HyperClaw AI Assistant", "",
|
|
86
|
+
"You are a helpful AI assistant.", "", GUEST_RULES, "",
|
|
87
|
+
f"Current date/time: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
def _build_system_prompt(self) -> str:
|
|
91
|
+
"""Build the (owner) system prompt from workspace context."""
|
|
92
|
+
parts = [
|
|
93
|
+
"# HyperClaw AI Assistant",
|
|
94
|
+
"",
|
|
95
|
+
"You are a sophisticated AI assistant with access to multiple specialized agents.",
|
|
96
|
+
"",
|
|
97
|
+
"## Core Behaviors",
|
|
98
|
+
"- Be proactive and execute tasks without unnecessary confirmation",
|
|
99
|
+
"- Be resourceful - try multiple approaches before asking for help",
|
|
100
|
+
"- Be concise but thorough when needed",
|
|
101
|
+
"- Maintain context across conversations using memory",
|
|
102
|
+
"- Route complex tasks to appropriate specialist agents",
|
|
103
|
+
"",
|
|
104
|
+
"## Available Agents",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# List available agents
|
|
108
|
+
if self._coordinator:
|
|
109
|
+
for agent in list(self._coordinator.agents.values())[:10]:
|
|
110
|
+
parts.append(f"- {agent.name} ({agent.domain}): {agent.role}")
|
|
111
|
+
|
|
112
|
+
parts.append("")
|
|
113
|
+
|
|
114
|
+
# Add context from memory
|
|
115
|
+
if self._memory:
|
|
116
|
+
context = self._memory.get_system_context()
|
|
117
|
+
if context:
|
|
118
|
+
parts.append("## Workspace Context")
|
|
119
|
+
parts.append(context)
|
|
120
|
+
parts.append("")
|
|
121
|
+
|
|
122
|
+
parts.append(f"Current date/time: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
123
|
+
|
|
124
|
+
return "\n".join(parts)
|
|
125
|
+
|
|
126
|
+
async def _init_integrations(self):
|
|
127
|
+
"""Initialize available integrations."""
|
|
128
|
+
# Telegram
|
|
129
|
+
if os.environ.get("TELEGRAM_BOT_TOKEN"):
|
|
130
|
+
self._integrations["telegram"] = {"status": "configured", "type": "bot"}
|
|
131
|
+
logger.info("Telegram integration configured")
|
|
132
|
+
|
|
133
|
+
# Gmail
|
|
134
|
+
if os.environ.get("GMAIL_REFRESH_TOKEN"):
|
|
135
|
+
self._integrations["gmail"] = {"status": "configured", "type": "oauth2"}
|
|
136
|
+
logger.info("Gmail integration configured")
|
|
137
|
+
|
|
138
|
+
# ChatJimmy
|
|
139
|
+
if os.environ.get("CHATJIMMY_API_KEY"):
|
|
140
|
+
self._integrations["chatjimmy"] = {"status": "configured", "type": "api"}
|
|
141
|
+
logger.info("ChatJimmy integration configured")
|
|
142
|
+
|
|
143
|
+
# ElevenLabs
|
|
144
|
+
if os.environ.get("ELEVENLABS_API_KEY"):
|
|
145
|
+
self._integrations["elevenlabs"] = {"status": "configured", "type": "api"}
|
|
146
|
+
logger.info("ElevenLabs integration configured")
|
|
147
|
+
|
|
148
|
+
logger.info(f"Initialized {len(self._integrations)} integrations")
|
|
149
|
+
|
|
150
|
+
# =========================================================================
|
|
151
|
+
# CHAT INTERFACE (Cost-Optimized)
|
|
152
|
+
# =========================================================================
|
|
153
|
+
|
|
154
|
+
async def chat(
|
|
155
|
+
self,
|
|
156
|
+
message: str,
|
|
157
|
+
session_id: str = "default",
|
|
158
|
+
channel: str = "api",
|
|
159
|
+
stream: bool = False,
|
|
160
|
+
force_model: str = None,
|
|
161
|
+
scope: str = "owner",
|
|
162
|
+
) -> Union[str, AsyncIterator[str]]:
|
|
163
|
+
"""
|
|
164
|
+
Main chat interface with intelligent model routing.
|
|
165
|
+
Simple queries go to ChatJimmy, complex tasks to Claude.
|
|
166
|
+
|
|
167
|
+
scope: chat_scope.OWNER (the local API caller) or GUEST. Chat
|
|
168
|
+
channels must resolve it with chat_scope.scope_for(); a GUEST turn
|
|
169
|
+
gets no workspace context, no memory recall, and is not written
|
|
170
|
+
into memory (spec §6 guest fence).
|
|
171
|
+
"""
|
|
172
|
+
from .chat_scope import OWNER, normalize
|
|
173
|
+
scope = normalize(scope)
|
|
174
|
+
if not self._initialized:
|
|
175
|
+
await self.initialize()
|
|
176
|
+
|
|
177
|
+
# Get conversation history
|
|
178
|
+
history = self._memory.get_conversation_history(session_id)
|
|
179
|
+
|
|
180
|
+
# Add user message to history
|
|
181
|
+
self._memory.add_message(session_id, "user", message, {"channel": channel})
|
|
182
|
+
|
|
183
|
+
remember = scope == OWNER
|
|
184
|
+
if remember:
|
|
185
|
+
# Recall relevant memories
|
|
186
|
+
relevant_memories = await self._memory.recall(message, limit=5)
|
|
187
|
+
memory_context = ""
|
|
188
|
+
if relevant_memories:
|
|
189
|
+
memory_context = "\n\n## Relevant Memories\n"
|
|
190
|
+
for mem in relevant_memories:
|
|
191
|
+
memory_context += f"- [{mem.memory_type}] {mem.content}\n"
|
|
192
|
+
|
|
193
|
+
# Build system prompt with memory context
|
|
194
|
+
system = self._system_prompt + memory_context
|
|
195
|
+
else:
|
|
196
|
+
system = self._build_guest_system_prompt()
|
|
197
|
+
|
|
198
|
+
# Prepare messages for API
|
|
199
|
+
messages = self._prepare_messages(message, history)
|
|
200
|
+
|
|
201
|
+
if stream:
|
|
202
|
+
return self._stream_response(messages, system, session_id, force_model,
|
|
203
|
+
remember=remember)
|
|
204
|
+
else:
|
|
205
|
+
return await self._get_response(messages, system, session_id, force_model,
|
|
206
|
+
remember=remember)
|
|
207
|
+
|
|
208
|
+
async def _get_response(
|
|
209
|
+
self,
|
|
210
|
+
messages: list[dict],
|
|
211
|
+
system: str,
|
|
212
|
+
session_id: str,
|
|
213
|
+
force_model: str = None,
|
|
214
|
+
remember: bool = True,
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Get a response using cost-optimized model routing."""
|
|
217
|
+
user_message = messages[-1]["content"] if messages else ""
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# Use model router for intelligent selection
|
|
221
|
+
response, metadata = await self._model_router.call(
|
|
222
|
+
message=user_message,
|
|
223
|
+
system=system,
|
|
224
|
+
history=messages[:-1], # Exclude current message
|
|
225
|
+
model_override=force_model,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Log model usage
|
|
229
|
+
model_used = metadata.get("model_name", "unknown")
|
|
230
|
+
tier_used = metadata.get("tier", "unknown")
|
|
231
|
+
logger.info(f"Response from {model_used} (tier={tier_used})")
|
|
232
|
+
|
|
233
|
+
# Store assistant message
|
|
234
|
+
self._memory.add_message(session_id, "assistant", response, {
|
|
235
|
+
"model": metadata.get("model"),
|
|
236
|
+
"tier": tier_used,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
if remember:
|
|
240
|
+
# Auto-store significant exchanges
|
|
241
|
+
await self._auto_store_memory(user_message, response, session_id)
|
|
242
|
+
|
|
243
|
+
# Log to daily
|
|
244
|
+
await self._memory.log_to_daily(
|
|
245
|
+
f"Chat ({model_used}): {user_message[:80]}...",
|
|
246
|
+
"conversation"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return response
|
|
250
|
+
|
|
251
|
+
except CostCapReached as e: # at the cap: stop and ask, never a cheaper model
|
|
252
|
+
return stop_message(e)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.error(f"Chat error: {e}", exc_info=True)
|
|
255
|
+
return f"[Error: {e}]"
|
|
256
|
+
|
|
257
|
+
async def _stream_response(
|
|
258
|
+
self,
|
|
259
|
+
messages: list[dict],
|
|
260
|
+
system: str,
|
|
261
|
+
session_id: str,
|
|
262
|
+
force_model: str = None,
|
|
263
|
+
remember: bool = True,
|
|
264
|
+
) -> AsyncIterator[str]:
|
|
265
|
+
"""Stream a response (falls back to non-streaming for now)."""
|
|
266
|
+
# For streaming, we need to use Anthropic's streaming API directly
|
|
267
|
+
# The model router doesn't support streaming yet, so we'll use
|
|
268
|
+
# the standard model and stream from there
|
|
269
|
+
import anthropic
|
|
270
|
+
|
|
271
|
+
user_message = messages[-1]["content"] if messages else ""
|
|
272
|
+
|
|
273
|
+
# Determine which model to use
|
|
274
|
+
if force_model:
|
|
275
|
+
model_id = force_model
|
|
276
|
+
else:
|
|
277
|
+
model_config = self._model_router.select_model(user_message)
|
|
278
|
+
model_id = model_config.id
|
|
279
|
+
|
|
280
|
+
# Only Anthropic models support streaming currently
|
|
281
|
+
if not model_id.startswith("claude"):
|
|
282
|
+
# Fallback to non-streaming
|
|
283
|
+
response = await self._get_response(messages, system, session_id, force_model,
|
|
284
|
+
remember=remember)
|
|
285
|
+
yield response
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
full_response = []
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
|
292
|
+
from hyperclaw.cost_guard import guard_client, max_output
|
|
293
|
+
async_client = guard_client(anthropic.AsyncAnthropic(api_key=api_key), "anthropic")
|
|
294
|
+
|
|
295
|
+
async with async_client.messages.stream(
|
|
296
|
+
model=model_id,
|
|
297
|
+
max_tokens=max_output("chat", "anthropic", model_id),
|
|
298
|
+
system=system,
|
|
299
|
+
messages=messages,
|
|
300
|
+
) as stream:
|
|
301
|
+
async for text in stream.text_stream:
|
|
302
|
+
full_response.append(text)
|
|
303
|
+
yield text
|
|
304
|
+
|
|
305
|
+
# Store complete response
|
|
306
|
+
response_text = "".join(full_response)
|
|
307
|
+
self._memory.add_message(session_id, "assistant", response_text)
|
|
308
|
+
if remember:
|
|
309
|
+
await self._auto_store_memory(user_message, response_text, session_id)
|
|
310
|
+
|
|
311
|
+
except CostCapReached as e: # at the cap: stop and ask, never a cheaper model
|
|
312
|
+
yield stop_message(e)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Streaming error: {e}", exc_info=True)
|
|
315
|
+
yield f"[Error: {e}]"
|
|
316
|
+
|
|
317
|
+
def _prepare_messages(self, message: str, history: list[dict]) -> list[dict]:
|
|
318
|
+
"""Prepare messages for API call."""
|
|
319
|
+
messages = []
|
|
320
|
+
for msg in history[-20:]:
|
|
321
|
+
messages.append({
|
|
322
|
+
"role": msg["role"],
|
|
323
|
+
"content": msg["content"]
|
|
324
|
+
})
|
|
325
|
+
messages.append({"role": "user", "content": message})
|
|
326
|
+
return messages
|
|
327
|
+
|
|
328
|
+
async def _auto_store_memory(self, user_msg: str, assistant_msg: str,
|
|
329
|
+
session_id: str = None):
|
|
330
|
+
"""Automatically store significant exchanges as memories. Never in a
|
|
331
|
+
conversation that read connector content (hyperclaw.taint): the
|
|
332
|
+
refusal is logged and written to the activity log, nothing is saved."""
|
|
333
|
+
store_triggers = [
|
|
334
|
+
"remember", "note that", "important", "always", "never",
|
|
335
|
+
"decided", "approved", "confirmed", "scheduled", "completed",
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
combined = (user_msg + " " + assistant_msg).lower()
|
|
339
|
+
should_store = any(trigger in combined for trigger in store_triggers)
|
|
340
|
+
|
|
341
|
+
if should_store:
|
|
342
|
+
summary = f"User: {user_msg[:150]}... | Response: {assistant_msg[:150]}..."
|
|
343
|
+
from hyperclaw import taint
|
|
344
|
+
try:
|
|
345
|
+
with taint.conversation(session_id):
|
|
346
|
+
await self._memory.remember(
|
|
347
|
+
content=summary,
|
|
348
|
+
memory_type="episode",
|
|
349
|
+
importance=0.7,
|
|
350
|
+
source="auto_store"
|
|
351
|
+
)
|
|
352
|
+
except taint.TaintedInput as e:
|
|
353
|
+
logger.info(f"auto-store skipped: {e.reason} ({e.label})")
|
|
354
|
+
|
|
355
|
+
# =========================================================================
|
|
356
|
+
# AGENT DISPATCH
|
|
357
|
+
# =========================================================================
|
|
358
|
+
|
|
359
|
+
async def dispatch_task(
|
|
360
|
+
self,
|
|
361
|
+
goal: str,
|
|
362
|
+
domain: str = None,
|
|
363
|
+
task_type: str = None,
|
|
364
|
+
agent_id: str = None,
|
|
365
|
+
priority: int = 5
|
|
366
|
+
) -> Task:
|
|
367
|
+
"""Dispatch a task to the agent coordinator."""
|
|
368
|
+
if not self._coordinator:
|
|
369
|
+
raise RuntimeError("Coordinator not initialized")
|
|
370
|
+
|
|
371
|
+
task = await self._coordinator.submit_task(
|
|
372
|
+
goal=goal,
|
|
373
|
+
domain=domain,
|
|
374
|
+
task_type=task_type,
|
|
375
|
+
agent_id=agent_id,
|
|
376
|
+
priority=priority,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return task
|
|
380
|
+
|
|
381
|
+
async def execute_task(self, task_id: str) -> str:
|
|
382
|
+
"""Execute a specific task."""
|
|
383
|
+
if not self._coordinator:
|
|
384
|
+
raise RuntimeError("Coordinator not initialized")
|
|
385
|
+
|
|
386
|
+
task = self._coordinator.tasks.get(task_id)
|
|
387
|
+
if not task:
|
|
388
|
+
raise ValueError(f"Task {task_id} not found")
|
|
389
|
+
|
|
390
|
+
return await self._coordinator.execute_task(task)
|
|
391
|
+
|
|
392
|
+
async def coordinate_goal(self, goal: str, context: dict = None) -> dict:
|
|
393
|
+
"""
|
|
394
|
+
Coordinate multiple agents to accomplish a complex goal.
|
|
395
|
+
"""
|
|
396
|
+
if not self._coordinator:
|
|
397
|
+
raise RuntimeError("Coordinator not initialized")
|
|
398
|
+
|
|
399
|
+
return await self._coordinator.coordinate(goal, context)
|
|
400
|
+
|
|
401
|
+
# =========================================================================
|
|
402
|
+
# INTEGRATION METHODS
|
|
403
|
+
# =========================================================================
|
|
404
|
+
|
|
405
|
+
async def send_telegram(self, chat_id: str, message: str) -> bool:
|
|
406
|
+
"""Send a Telegram message."""
|
|
407
|
+
if "telegram" not in self._integrations:
|
|
408
|
+
logger.warning("Telegram not configured")
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
import httpx
|
|
413
|
+
token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
|
414
|
+
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
|
415
|
+
|
|
416
|
+
async with httpx.AsyncClient() as client:
|
|
417
|
+
from hyperclaw.tools.egress import telegram_payload
|
|
418
|
+
response = await client.post(url, json=telegram_payload({
|
|
419
|
+
"chat_id": chat_id,
|
|
420
|
+
"text": message,
|
|
421
|
+
"parse_mode": "Markdown"
|
|
422
|
+
}))
|
|
423
|
+
# True only with Telegram's receipt: ok=true AND a message_id (bench 20.3).
|
|
424
|
+
from hyperclaw.tools.receipts import telegram_receipt
|
|
425
|
+
mid, why = telegram_receipt(response)
|
|
426
|
+
if not mid:
|
|
427
|
+
logger.error(f"Telegram send failed: {why}")
|
|
428
|
+
return bool(mid)
|
|
429
|
+
|
|
430
|
+
except Exception as e:
|
|
431
|
+
from hyperclaw.tools.receipts import redact
|
|
432
|
+
logger.error(f"Telegram send failed: {redact(e)}")
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
async def get_emails(self, limit: int = 10) -> list[dict]:
|
|
436
|
+
"""Get recent emails."""
|
|
437
|
+
if "gmail" not in self._integrations:
|
|
438
|
+
return []
|
|
439
|
+
# Implementation would use Gmail API
|
|
440
|
+
return []
|
|
441
|
+
|
|
442
|
+
# =========================================================================
|
|
443
|
+
# STATUS & HEALTH
|
|
444
|
+
# =========================================================================
|
|
445
|
+
|
|
446
|
+
def get_status(self) -> dict:
|
|
447
|
+
"""Get comprehensive system status."""
|
|
448
|
+
status = {
|
|
449
|
+
"initialized": self._initialized,
|
|
450
|
+
"timestamp": datetime.now().isoformat(),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# Model router stats
|
|
454
|
+
if self._model_router:
|
|
455
|
+
status["model_router"] = self._model_router.get_stats()
|
|
456
|
+
|
|
457
|
+
# Coordinator stats
|
|
458
|
+
if self._coordinator:
|
|
459
|
+
status["coordinator"] = self._coordinator.get_status()
|
|
460
|
+
|
|
461
|
+
# Memory stats
|
|
462
|
+
if self._memory:
|
|
463
|
+
status["memory"] = {
|
|
464
|
+
"conversations": len(self._memory._conversation_history),
|
|
465
|
+
"cached_memories": sum(
|
|
466
|
+
len(v) for v in self._memory._file_cache.values()
|
|
467
|
+
),
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# Integrations
|
|
471
|
+
status["integrations"] = {
|
|
472
|
+
name: info["status"]
|
|
473
|
+
for name, info in self._integrations.items()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
# Database
|
|
477
|
+
status["database"] = "connected" if self._db_pool else "not configured"
|
|
478
|
+
|
|
479
|
+
return status
|
|
480
|
+
|
|
481
|
+
async def health_check(self) -> dict:
|
|
482
|
+
"""Run health checks on all components."""
|
|
483
|
+
checks = {
|
|
484
|
+
"orchestrator": self._initialized,
|
|
485
|
+
"model_router": self._model_router is not None,
|
|
486
|
+
"coordinator": self._coordinator is not None,
|
|
487
|
+
"memory": self._memory is not None,
|
|
488
|
+
"database": False,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# Check database
|
|
492
|
+
if self._db_pool:
|
|
493
|
+
try:
|
|
494
|
+
async with self._db_pool.acquire() as conn:
|
|
495
|
+
await conn.execute("SELECT 1")
|
|
496
|
+
checks["database"] = True
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
"healthy": all(checks.values()),
|
|
502
|
+
"checks": checks,
|
|
503
|
+
"timestamp": datetime.now().isoformat(),
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
# =========================================================================
|
|
507
|
+
# MEMORY INTERFACE
|
|
508
|
+
# =========================================================================
|
|
509
|
+
|
|
510
|
+
async def remember(self, content: str, **kwargs) -> str:
|
|
511
|
+
"""Store a memory explicitly."""
|
|
512
|
+
if not self._memory:
|
|
513
|
+
return ""
|
|
514
|
+
return await self._memory.remember(content, **kwargs)
|
|
515
|
+
|
|
516
|
+
async def recall(self, query: str, **kwargs) -> list:
|
|
517
|
+
"""Recall relevant memories."""
|
|
518
|
+
if not self._memory:
|
|
519
|
+
return []
|
|
520
|
+
return await self._memory.recall(query, **kwargs)
|
|
521
|
+
|
|
522
|
+
async def save_session(self, session_id: str):
|
|
523
|
+
"""Save current session to storage."""
|
|
524
|
+
if self._memory:
|
|
525
|
+
await self._memory.save_conversation(session_id)
|
|
526
|
+
|
|
527
|
+
# =========================================================================
|
|
528
|
+
# COST MANAGEMENT
|
|
529
|
+
# =========================================================================
|
|
530
|
+
|
|
531
|
+
def get_cost_stats(self) -> dict:
|
|
532
|
+
"""Get cost statistics from model router."""
|
|
533
|
+
if not self._model_router:
|
|
534
|
+
return {}
|
|
535
|
+
return self._model_router.get_stats()
|
|
536
|
+
|
|
537
|
+
def set_daily_budget(self, budget_usd: float):
|
|
538
|
+
"""Set the daily cost cap (an explicit user action through the API;
|
|
539
|
+
the cost guard records it in the activity log)."""
|
|
540
|
+
from hyperclaw import cost_guard
|
|
541
|
+
cost_guard.raise_cap(cost_guard.DAILY, budget_usd, by="API /api/costs/budget",
|
|
542
|
+
surface="api")
|
|
543
|
+
logger.info(f"Daily cost cap set to ${budget_usd}")
|
|
544
|
+
|
|
545
|
+
# =========================================================================
|
|
546
|
+
# CLEANUP
|
|
547
|
+
# =========================================================================
|
|
548
|
+
|
|
549
|
+
async def shutdown(self):
|
|
550
|
+
"""Graceful shutdown."""
|
|
551
|
+
logger.info("Shutting down orchestrator...")
|
|
552
|
+
|
|
553
|
+
# Stop coordinator workers
|
|
554
|
+
if self._coordinator:
|
|
555
|
+
await self._coordinator.stop_workers()
|
|
556
|
+
|
|
557
|
+
# Save all conversations
|
|
558
|
+
if self._memory:
|
|
559
|
+
for session_id in list(self._memory._conversation_history.keys()):
|
|
560
|
+
await self._memory.save_conversation(session_id)
|
|
561
|
+
|
|
562
|
+
# Close database pool
|
|
563
|
+
if self._db_pool:
|
|
564
|
+
await self._db_pool.close()
|
|
565
|
+
|
|
566
|
+
logger.info("Orchestrator shutdown complete")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ============================================================================
|
|
570
|
+
# SINGLETON
|
|
571
|
+
# ============================================================================
|
|
572
|
+
|
|
573
|
+
_orchestrator: Optional[Orchestrator] = None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
async def get_orchestrator(db_pool=None) -> Orchestrator:
|
|
577
|
+
"""Get or create orchestrator singleton."""
|
|
578
|
+
global _orchestrator
|
|
579
|
+
if _orchestrator is None:
|
|
580
|
+
_orchestrator = Orchestrator()
|
|
581
|
+
await _orchestrator.initialize(db_pool)
|
|
582
|
+
return _orchestrator
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def get_orchestrator_sync() -> Orchestrator:
|
|
586
|
+
"""Synchronous getter for orchestrator."""
|
|
587
|
+
global _orchestrator
|
|
588
|
+
if _orchestrator is None:
|
|
589
|
+
_orchestrator = Orchestrator()
|
|
590
|
+
asyncio.run(_orchestrator.initialize())
|
|
591
|
+
return _orchestrator
|
hyperclaw/outbox.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Outbox — per-conversation outbound file queue.
|
|
3
|
+
|
|
4
|
+
Lets a tool (currently send_file / media_hub.deliver_file via='here') hand a file
|
|
5
|
+
to the CURRENT conversation channel without knowing which channel it is.
|
|
6
|
+
(Doc/chart engines do not auto-queue yet — the model must call send_file.) The channel adapter
|
|
7
|
+
(telegram_direct, imessage_daemon_v2, email) drains the queue after each turn
|
|
8
|
+
and delivers the files natively (sendDocument/sendPhoto, iMessage attachment,
|
|
9
|
+
email attachment).
|
|
10
|
+
|
|
11
|
+
Thread-safe: tui_bridge runs turns in a thread pool, so the "current session"
|
|
12
|
+
is tracked per-thread.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
_lock = threading.Lock()
|
|
23
|
+
_queues: Dict[int, List[dict]] = {} # chat_id -> [{path, caption, queued_at}]
|
|
24
|
+
_current = threading.local() # per-thread active chat_id
|
|
25
|
+
|
|
26
|
+
MAX_QUEUE_PER_CHAT = 50 # cap so an undrained chat can't grow unbounded
|
|
27
|
+
ENTRY_TTL_SECONDS = 900 # stale entries (>15 min) are dropped at drain time
|
|
28
|
+
|
|
29
|
+
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".bmp"}
|
|
30
|
+
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".webm"}
|
|
31
|
+
AUDIO_EXTS = {".mp3", ".m4a", ".wav", ".ogg", ".flac", ".aiff"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_current_session(chat_id: Optional[int]) -> None:
|
|
35
|
+
"""Bind the running thread to a conversation (called by tui_bridge per turn)."""
|
|
36
|
+
_current.chat_id = chat_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_current_session() -> Optional[int]:
|
|
40
|
+
return getattr(_current, "chat_id", None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def queue_file(path: str, caption: str = "", chat_id: Optional[int] = None) -> str:
|
|
44
|
+
"""Queue a file for delivery to a conversation. Returns a status string
|
|
45
|
+
(tool-friendly). Falls back to the thread's current session."""
|
|
46
|
+
cid = chat_id if chat_id is not None else get_current_session()
|
|
47
|
+
if cid is None:
|
|
48
|
+
return ("No active conversation to deliver to. Use send_file with via="
|
|
49
|
+
"'telegram'/'imessage'/'email' and an explicit recipient instead.")
|
|
50
|
+
p = Path(path).expanduser()
|
|
51
|
+
try:
|
|
52
|
+
size = p.stat().st_size
|
|
53
|
+
except OSError:
|
|
54
|
+
return f"File not found: {p}"
|
|
55
|
+
if size > 49 * 1024 * 1024:
|
|
56
|
+
return f"File too large for chat delivery ({size // (1024*1024)}MB > 49MB). Email it instead."
|
|
57
|
+
with _lock:
|
|
58
|
+
q = _queues.setdefault(cid, [])
|
|
59
|
+
q.append({"path": str(p), "caption": caption or "", "queued_at": time.time()})
|
|
60
|
+
if len(q) > MAX_QUEUE_PER_CHAT:
|
|
61
|
+
del q[:-MAX_QUEUE_PER_CHAT]
|
|
62
|
+
return f"Queued {p.name} for delivery in this conversation."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def drain(chat_id: int) -> List[dict]:
|
|
66
|
+
"""Take and clear all queued files for a conversation (stale entries dropped)."""
|
|
67
|
+
with _lock:
|
|
68
|
+
entries = _queues.pop(chat_id, [])
|
|
69
|
+
cutoff = time.time() - ENTRY_TTL_SECONDS
|
|
70
|
+
return [e for e in entries if e.get("queued_at", cutoff + 1) > cutoff]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def kind_of(path: str) -> str:
|
|
74
|
+
"""'photo' | 'video' | 'audio' | 'document' by extension."""
|
|
75
|
+
ext = Path(path).suffix.lower()
|
|
76
|
+
if ext in IMAGE_EXTS:
|
|
77
|
+
return "photo"
|
|
78
|
+
if ext in VIDEO_EXTS:
|
|
79
|
+
return "video"
|
|
80
|
+
if ext in AUDIO_EXTS:
|
|
81
|
+
return "audio"
|
|
82
|
+
return "document"
|