hyperagents 1.2.3rc5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,1427 @@
1
+ """
2
+ Assistant Integration Layer
3
+ Unified interface for Gmail, Calendar, iMessage, Supabase, Telegram
4
+ All wired into HyperClaw agent tool execution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import logging
15
+ from datetime import datetime, timezone, timedelta
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ import httpx
20
+ from dotenv import load_dotenv
21
+
22
+ load_dotenv(str(Path.home() / '.hyperclaw/.env'))
23
+
24
+ logger = logging.getLogger("hyperclaw.integrations_layer")
25
+
26
+ # workspace/secrets/ or the secrets/ dir onboarding creates (realpath-resolved)
27
+ from hyperclaw.api_utils import gmail_token_path
28
+ from hyperclaw.taint import source as _taint_source, training_sink
29
+ GMAIL_TOKEN_FILE = gmail_token_path()
30
+ GOOGLE_CREDS_FILE = Path(os.getenv('GOOGLE_CREDENTIALS_FILE', ''))
31
+
32
+ # ─────────────────────────────────────────────
33
+ # TOKEN MANAGER
34
+ # ─────────────────────────────────────────────
35
+
36
+ class GoogleTokenManager:
37
+ """Manages Google OAuth tokens with auto-refresh.
38
+
39
+ Hardened version: always checks expiry, auto-refreshes on 401,
40
+ reloads from disk every 30s (not 5min), and retries with env fallbacks.
41
+ Self-heals by syncing refresh_token from .env if token file's is revoked.
42
+ """
43
+
44
+ _token_data: dict = {}
45
+ _last_loaded: float = 0
46
+ _refresh_failures: int = 0
47
+ # Circuit breaker: stop hammering Google once the grant is dead/revoked.
48
+ MAX_FAILURES: int = 5
49
+ _circuit_open_until: float = 0.0 # epoch seconds; while now < this, _refresh() is a no-op
50
+ _alerted: bool = False # ensures only ONE reauth alert per outage
51
+ _auth_revoked: bool = False # terminal state: refresh token returned invalid_grant
52
+
53
+ @classmethod
54
+ def load(cls, force: bool = False) -> dict:
55
+ """Load token from disk. Reloads every 30s or on force."""
56
+ import time
57
+ if force or (time.time() - cls._last_loaded > 30):
58
+ if GMAIL_TOKEN_FILE.exists():
59
+ try:
60
+ with open(GMAIL_TOKEN_FILE) as f:
61
+ cls._token_data = json.load(f)
62
+ cls._last_loaded = time.time()
63
+ logger.debug(f"Loaded token from {GMAIL_TOKEN_FILE} (refresh: {cls._token_data.get('refresh_token','')[:20]}...)")
64
+ except Exception as e:
65
+ logger.error(f"Failed to load token file {GMAIL_TOKEN_FILE}: {e}")
66
+ else:
67
+ logger.warning(f"Token file not found: {GMAIL_TOKEN_FILE}")
68
+ return cls._token_data
69
+
70
+ @classmethod
71
+ def _is_expired(cls, data: dict) -> bool:
72
+ """Check if token is expired or will expire within 10 minutes."""
73
+ expiry_str = data.get('expiry')
74
+ if not expiry_str:
75
+ return True # No expiry = assume expired
76
+ try:
77
+ expiry = datetime.fromisoformat(expiry_str.replace('Z', '+00:00'))
78
+ if expiry.tzinfo is None:
79
+ expiry = expiry.replace(tzinfo=timezone.utc)
80
+ now = datetime.now(timezone.utc)
81
+ return now >= expiry - timedelta(minutes=10)
82
+ except Exception:
83
+ return True # Can't parse = assume expired
84
+
85
+ @classmethod
86
+ def get_access_token(cls) -> str:
87
+ """Get valid access token, refreshing if needed."""
88
+ data = cls.load()
89
+
90
+ # Support both 'token' and 'access_token' keys
91
+ token = data.get('token', '') or data.get('access_token', '')
92
+
93
+ # If token is empty, force reload from disk
94
+ if not token:
95
+ data = cls.load(force=True)
96
+ token = data.get('token', '') or data.get('access_token', '')
97
+
98
+ # If still empty or expired, refresh
99
+ if not token or cls._is_expired(data):
100
+ cls._refresh()
101
+ data = cls._token_data
102
+ token = data.get('token', '') or data.get('access_token', '')
103
+
104
+ return token
105
+
106
+ @classmethod
107
+ def handle_401(cls) -> str:
108
+ """Called when a Gmail API call returns 401. Force refresh and return new token."""
109
+ logger.warning("Got 401 from Gmail API, force-refreshing token...")
110
+ cls._last_loaded = 0 # Force disk reload first
111
+ cls.load(force=True)
112
+ cls._refresh()
113
+ data = cls._token_data
114
+ return data.get('token', '') or data.get('access_token', '')
115
+
116
+ @classmethod
117
+ def is_healthy(cls) -> bool:
118
+ """True when Gmail/Calendar auth is usable. Used to gate email send/bump/escalation so
119
+ The assistant does not generate work (bumps, SLA breaches) it cannot deliver while the token is dead."""
120
+ if cls._auth_revoked:
121
+ return False
122
+ try:
123
+ tok = cls.get_access_token()
124
+ return bool(tok) and cls._refresh_failures == 0 and not cls._is_expired(cls._token_data)
125
+ except Exception:
126
+ return False
127
+
128
+ @classmethod
129
+ def _refresh(cls) -> None:
130
+ """Refresh the access token. Tries token file creds, then env var fallbacks.
131
+
132
+ Self-healing: if the token file's refresh_token is revoked but the env var's works,
133
+ automatically update the token file with the working refresh_token.
134
+ """
135
+ import time
136
+ # Circuit breaker: after repeated failures (or a revoked grant) we back off instead of
137
+ # retrying on every single Gmail/Calendar call. This is what stopped the 8000+ retries.
138
+ if time.time() < cls._circuit_open_until:
139
+ return
140
+ data = cls._token_data
141
+
142
+ # Build credential pairs to try (token file first, then env vars)
143
+ cred_sets = []
144
+ file_refresh = data.get('refresh_token', '')
145
+
146
+ # From token file
147
+ if data.get('client_id') and file_refresh:
148
+ cred_sets.append({
149
+ 'client_id': data['client_id'],
150
+ 'client_secret': data.get('client_secret', ''),
151
+ 'refresh_token': file_refresh,
152
+ 'source': 'token_file',
153
+ })
154
+
155
+ # From env vars (as fallback)
156
+ env_client_id = os.getenv('GMAIL_CLIENT_ID', '')
157
+ env_client_secret = os.getenv('GMAIL_CLIENT_SECRET', '')
158
+ env_refresh = os.getenv('GMAIL_REFRESH_TOKEN', '')
159
+ if env_client_id and env_refresh and env_refresh != file_refresh:
160
+ cred_sets.append({
161
+ 'client_id': env_client_id,
162
+ 'client_secret': env_client_secret,
163
+ 'refresh_token': env_refresh,
164
+ 'source': 'env_vars',
165
+ })
166
+
167
+ for i, creds in enumerate(cred_sets):
168
+ source = creds.pop('source', f'set_{i}')
169
+ try:
170
+ params = {**creds, 'grant_type': 'refresh_token'}
171
+ with httpx.Client(timeout=15) as client:
172
+ resp = client.post('https://oauth2.googleapis.com/token', data=params)
173
+ result = resp.json()
174
+
175
+ if 'access_token' in result:
176
+ new_token = result['access_token']
177
+ expires_in = result.get('expires_in', 3600)
178
+ expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
179
+
180
+ cls._token_data['token'] = new_token
181
+ cls._token_data['access_token'] = new_token
182
+ cls._token_data['expiry'] = expiry.isoformat()
183
+
184
+ # Self-heal: if env var worked but file didn't, sync the working token to file
185
+ if source == 'env_vars' and creds['refresh_token'] != file_refresh:
186
+ logger.warning("Self-healing: updating token file with working refresh_token from env")
187
+ cls._token_data['refresh_token'] = creds['refresh_token']
188
+ cls._token_data['client_id'] = creds['client_id']
189
+ cls._token_data['client_secret'] = creds['client_secret']
190
+
191
+ # Handle token rotation (Google may issue new refresh tokens)
192
+ if result.get('refresh_token'):
193
+ cls._token_data['refresh_token'] = result['refresh_token']
194
+ logger.info("Google returned a rotated refresh_token, saved.")
195
+
196
+ # Persist to disk
197
+ try:
198
+ with open(GMAIL_TOKEN_FILE, 'w') as f:
199
+ json.dump(cls._token_data, f, indent=2)
200
+ except Exception as e:
201
+ logger.error(f"Failed to persist token to disk: {e}")
202
+
203
+ cls._last_loaded = 0 # Force reload next time
204
+ cls._refresh_failures = 0
205
+ cls._alerted = False
206
+ cls._circuit_open_until = 0.0
207
+ cls._auth_revoked = False
208
+ logger.info(f"Google token refreshed (source: {source}), expires {expiry.isoformat()}")
209
+ return
210
+ else:
211
+ error_code = str(result.get('error', '')).lower()
212
+ error_desc = result.get('error_description', result.get('error', 'unknown'))
213
+ logger.warning(f"Refresh attempt {i} ({source}) failed: {error_desc}")
214
+ # invalid_grant / unauthorized_client are PERMANENT (revoked refresh token or
215
+ # wrong client). Trying more creds or retrying later is futile until reauth.
216
+ if error_code in ('invalid_grant', 'unauthorized_client'):
217
+ cls._auth_revoked = True
218
+ break
219
+ except Exception as e:
220
+ logger.warning(f"Token refresh attempt {i} ({source}) failed: {e}")
221
+
222
+ cls._refresh_failures += 1
223
+ logger.error(f"All token refresh attempts failed (failure #{cls._refresh_failures})")
224
+ # Trip the circuit once we hit the ceiling or the grant is terminally revoked, and alert
225
+ # the owner exactly once (instead of thousands of silent failures filling the logs).
226
+ if cls._auth_revoked or cls._refresh_failures >= cls.MAX_FAILURES:
227
+ cooldown = 3600 if cls._auth_revoked else 900 # revoked needs human; transient backs off
228
+ cls._circuit_open_until = time.time() + cooldown
229
+ if not cls._alerted:
230
+ cls._alerted = True
231
+ _send_gmail_reauth_alert(revoked=cls._auth_revoked)
232
+
233
+
234
+ def _send_gmail_reauth_alert(revoked: bool = True) -> None:
235
+ """Post a Gmail-reauth Telegram alert, at most once per hour ACROSS ALL processes.
236
+ Best-effort, never raises. The file-based cooldown prevents every daemon (hermes, briefing,
237
+ server, CLI) from each firing its own alert during the same outage."""
238
+ try:
239
+ import time as _t
240
+ stamp = Path.home() / '.hyperclaw/data/.gmail_alert_sent'
241
+ try:
242
+ if stamp.exists() and (_t.time() - stamp.stat().st_mtime) < 3600:
243
+ return # another process already alerted within the last hour
244
+ except Exception:
245
+ pass
246
+
247
+ token = os.getenv('TELEGRAM_BOT_TOKEN', '')
248
+ chat_id = os.getenv('TELEGRAM_CHAT_ID', '')
249
+ if not token or not chat_id:
250
+ return
251
+ if revoked:
252
+ text = ("Gmail auth is down. The OAuth refresh token is revoked (invalid_grant). "
253
+ "Email send and follow-ups are paused until reauth.\n"
254
+ "Fix: run python3 ~/.hyperclaw/scripts/gmail_reauth.py on the Mac, then publish "
255
+ "the OAuth app to Production in Google Cloud so it stops expiring weekly.")
256
+ else:
257
+ text = ("Gmail token refresh has failed repeatedly; email send/follow-ups may be "
258
+ "degraded. If it persists, run python3 ~/.hyperclaw/scripts/gmail_reauth.py")
259
+ with httpx.Client(timeout=10) as client:
260
+ from hyperclaw.tools.egress import telegram_payload
261
+ client.post(f'https://api.telegram.org/bot{token}/sendMessage',
262
+ json=telegram_payload({'chat_id': chat_id, 'text': text}))
263
+ try:
264
+ stamp.parent.mkdir(parents=True, exist_ok=True)
265
+ stamp.write_text(str(int(_t.time())))
266
+ except Exception:
267
+ pass
268
+ logger.error("Sent one-time Gmail reauth alert to Telegram")
269
+ except Exception as e:
270
+ logger.warning(f"Failed to send Gmail reauth alert: {e}")
271
+
272
+
273
+ # ─────────────────────────────────────────────
274
+ # GMAIL
275
+ # ─────────────────────────────────────────────
276
+
277
+ def _gmail_request(method: str, url: str, **kwargs) -> httpx.Response:
278
+ """Make a Gmail API request with automatic 401 retry."""
279
+ token = GoogleTokenManager.get_access_token()
280
+ headers = kwargs.pop('headers', {})
281
+ headers['Authorization'] = f'Bearer {token}'
282
+
283
+ with httpx.Client(timeout=30) as client:
284
+ r = getattr(client, method)(url, headers=headers, **kwargs)
285
+
286
+ # Auto-retry on 401
287
+ if r.status_code == 401:
288
+ logger.warning("Gmail 401 - auto-refreshing token and retrying...")
289
+ new_token = GoogleTokenManager.handle_401()
290
+ if new_token:
291
+ headers['Authorization'] = f'Bearer {new_token}'
292
+ with httpx.Client(timeout=30) as client:
293
+ r = getattr(client, method)(url, headers=headers, **kwargs)
294
+
295
+ return r
296
+
297
+
298
+ @_taint_source("gmail", "google")
299
+ def gmail_list_inbox(max_results: int = 20, query: str = 'in:inbox') -> dict:
300
+ """List inbox messages."""
301
+ r = _gmail_request(
302
+ 'get',
303
+ 'https://gmail.googleapis.com/gmail/v1/users/me/messages',
304
+ params={'q': query, 'maxResults': max_results}
305
+ )
306
+
307
+ if r.status_code != 200:
308
+ return {'error': f'Gmail list failed: {r.status_code} {r.text[:200]}'}
309
+
310
+ return r.json()
311
+
312
+
313
+ @_taint_source("gmail", "google")
314
+ def gmail_get_message(message_id: str, format: str = 'full') -> dict:
315
+ """Get a specific message."""
316
+ r = _gmail_request(
317
+ 'get',
318
+ f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}',
319
+ params={'format': format}
320
+ )
321
+
322
+ return r.json() if r.status_code == 200 else {'error': r.text[:200]}
323
+
324
+
325
+ import re as _re_attach
326
+ # Forward-looking phrases that mean "a file is attached" (not benign uses of 'attached').
327
+ _ATTACH_PROMISE_RE = _re_attach.compile(
328
+ r"(please find attached|see attached|find attached|attached (is|are|please|herewith|you will find)|"
329
+ r"i[' ]?ve attached|i have attached|i am attaching|i'm attaching|attaching the|the attached (file|document|"
330
+ r"deck|report|spreadsheet|pdf|doc)|enclosed (is|are|please)|\bpfa\b|attachment:)",
331
+ _re_attach.IGNORECASE,
332
+ )
333
+
334
+
335
+ def _body_promises_attachment(body: str) -> bool:
336
+ """True if the body clearly promises an attached file."""
337
+ return bool(_ATTACH_PROMISE_RE.search(body or ""))
338
+
339
+
340
+ def gmail_send(to: str, subject: str, body: str, cc: str = '', reply_to_id: str = '', attachments: list = None, in_reply_to: str = None, references: str = None, bcc: str = '', allow_unattached: bool = False, body_html: str = None) -> dict:
341
+ """Send an email via Gmail with optional attachments.
342
+
343
+ Args:
344
+ to: Recipient email address(es), comma-separated
345
+ subject: Email subject
346
+ body: Email body text
347
+ cc: Additional CC recipients (comma-separated)
348
+ bcc: BCC recipients (comma-separated) - use for a private side-channel to yourself
349
+ reply_to_id: Thread ID to reply to (keeps email in same thread)
350
+ attachments: List of file paths to attach
351
+ in_reply_to: Message-ID to reply to (for In-Reply-To header)
352
+ references: Full References header chain (defaults to in_reply_to)
353
+ allow_unattached: bypass the attachment-promise guard (only if intentionally no file)
354
+
355
+ Returns: {status, id, threadId, to, cc, bcc} on success or {error} on failure
356
+
357
+ Threading: when reply_to_id (a Gmail threadId) is given but in_reply_to is not, this
358
+ auto-resolves the parent Message-ID + References chain from the thread and forces the subject
359
+ to match the thread. Without those headers replies fork into a NEW thread - which was the bug.
360
+ """
361
+ import base64
362
+ import mimetypes
363
+ from email.mime.text import MIMEText
364
+ from email.mime.multipart import MIMEMultipart
365
+ from email.mime.base import MIMEBase
366
+ from email import encoders
367
+
368
+ token = GoogleTokenManager.get_access_token()
369
+ headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
370
+
371
+ # --- Make replies thread correctly for ALL callers (the new-thread-on-reply fix) ---
372
+ if reply_to_id and not in_reply_to:
373
+ try:
374
+ tr = _gmail_request(
375
+ 'get', f'https://gmail.googleapis.com/gmail/v1/users/me/threads/{reply_to_id}',
376
+ params={'format': 'metadata', 'metadataHeaders': ['Message-ID', 'References', 'Subject']}
377
+ )
378
+ if tr.status_code == 200:
379
+ tmsgs = tr.json().get('messages', [])
380
+ if tmsgs:
381
+ lh = {h['name'].lower(): h['value'] for h in tmsgs[-1].get('payload', {}).get('headers', [])}
382
+ in_reply_to = lh.get('message-id', '') or in_reply_to
383
+ parent_refs = lh.get('references', '')
384
+ if in_reply_to and not references:
385
+ references = (parent_refs + ' ' + in_reply_to).strip() if parent_refs else in_reply_to
386
+ # Gmail requires the subject to match the thread for a threadId send to stick.
387
+ tsubj = lh.get('subject', '')
388
+ if tsubj:
389
+ import re as _re
390
+ _norm = lambda s: _re.sub(r'^\s*(re|fwd|fw)\s*:\s*', '', (s or '').strip(),
391
+ flags=_re.IGNORECASE).strip().lower()
392
+ if _norm(subject) != _norm(tsubj):
393
+ subject = tsubj if tsubj.lower().startswith('re:') else f"Re: {tsubj}"
394
+ except Exception as e:
395
+ logger.debug(f"Reply threading resolve failed: {e}")
396
+
397
+ # Pre-send attachment guard: never send a message that promises a file it doesn't carry.
398
+ if not attachments and not allow_unattached and _body_promises_attachment(body):
399
+ return {'error': "BLOCKED_NO_ATTACHMENT: the body references an attached file but no file is "
400
+ "attached. Attach the file (attachments=[...] or email_send_with_attachment), "
401
+ "or reword the body, then resend. If intentional, pass allow_unattached=True."}
402
+
403
+ # Optionally CC a configured address on every outbound email (CC_EMAIL env). Empty = no auto-CC.
404
+ _auto_cc = os.environ.get('CC_EMAIL', '').strip()
405
+ cc_list = [_auto_cc] if _auto_cc else []
406
+ if cc and cc != _auto_cc:
407
+ cc_list.append(cc)
408
+ bcc_list = [b.strip() for b in (bcc or '').split(',') if b.strip()]
409
+
410
+ # Use mixed multipart for attachments/html, alternative for plain email
411
+ if attachments or body_html:
412
+ msg = MIMEMultipart('mixed')
413
+ else:
414
+ msg = MIMEMultipart('alternative')
415
+
416
+ msg['to'] = to
417
+ msg['subject'] = subject
418
+ msg['cc'] = ', '.join(cc_list)
419
+ if bcc_list:
420
+ msg['bcc'] = ', '.join(bcc_list)
421
+
422
+ # Add threading headers for replies
423
+ if in_reply_to:
424
+ msg['In-Reply-To'] = in_reply_to
425
+ msg['References'] = references or in_reply_to
426
+
427
+ # Body: plain always; when body_html is given, send a proper multipart/alternative
428
+ # so modern clients render rich formatting and plain-text clients still work.
429
+ if body_html:
430
+ alt = MIMEMultipart('alternative')
431
+ alt.attach(MIMEText(body, 'plain'))
432
+ alt.attach(MIMEText(body_html, 'html'))
433
+ msg.attach(alt)
434
+ else:
435
+ msg.attach(MIMEText(body, 'plain'))
436
+
437
+ # Attach files
438
+ if attachments:
439
+ for file_path in attachments:
440
+ path = Path(file_path)
441
+ if not path.exists():
442
+ return {'error': f'Attachment not found: {file_path}'}
443
+
444
+ mime_type, _ = mimetypes.guess_type(str(path))
445
+ if mime_type is None:
446
+ mime_type = 'application/octet-stream'
447
+
448
+ main_type, sub_type = mime_type.split('/', 1)
449
+
450
+ with open(path, 'rb') as f:
451
+ file_data = f.read()
452
+
453
+ part = MIMEBase(main_type, sub_type)
454
+ part.set_payload(file_data)
455
+ encoders.encode_base64(part)
456
+ part.add_header('Content-Disposition', 'attachment', filename=path.name)
457
+ msg.attach(part)
458
+
459
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
460
+ payload = {'raw': raw}
461
+ if reply_to_id:
462
+ payload['threadId'] = reply_to_id
463
+
464
+ with httpx.Client(timeout=60) as client:
465
+ r = client.post(
466
+ 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
467
+ headers=headers,
468
+ json=payload
469
+ )
470
+
471
+ # Auto-retry on 401
472
+ if r.status_code == 401:
473
+ logger.warning("Gmail send 401 - auto-refreshing token and retrying...")
474
+ new_token = GoogleTokenManager.handle_401()
475
+ if new_token:
476
+ headers['Authorization'] = f'Bearer {new_token}'
477
+ with httpx.Client(timeout=60) as client:
478
+ r = client.post(
479
+ 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
480
+ headers=headers,
481
+ json=payload
482
+ )
483
+
484
+ if r.status_code in (200, 201):
485
+ j = r.json()
486
+ return {'status': 'sent', 'id': j.get('id', ''), 'threadId': j.get('threadId', ''),
487
+ 'attachments': len(attachments or []),
488
+ 'to': to, 'cc': ', '.join(cc_list), 'bcc': ', '.join(bcc_list)}
489
+ return {'error': f'Send failed: {r.status_code} {r.text[:200]}'}
490
+
491
+
492
+ def gmail_reply(message_id: str, body: str, cc: str = '', attachments: list = None,
493
+ reply_all: bool = False) -> dict:
494
+ """Reply to a specific message, guaranteed in-thread.
495
+
496
+ Resolves the recipient, subject (Re:), threadId and In-Reply-To/References from the original
497
+ message, so callers only need the message_id + body. Use this for replies instead of gmail_send.
498
+ """
499
+ orig = gmail_get_message(message_id, format='metadata')
500
+ if 'error' in orig:
501
+ orig = gmail_get_message(message_id, format='full')
502
+ if 'error' in orig:
503
+ return {'error': f"Could not load message {message_id}: {orig.get('error')}"}
504
+
505
+ hdrs = {h['name'].lower(): h['value'] for h in orig.get('payload', {}).get('headers', [])}
506
+ thread_id = orig.get('threadId', '')
507
+
508
+ def _bare(addr: str) -> str:
509
+ addr = addr or ''
510
+ if '<' in addr and '>' in addr:
511
+ return addr[addr.find('<') + 1:addr.find('>')].strip()
512
+ return addr.strip()
513
+
514
+ to_addr = _bare(hdrs.get('reply-to') or hdrs.get('from', ''))
515
+ cc_arg = cc
516
+ if reply_all:
517
+ extras = [hdrs.get('to', ''), hdrs.get('cc', '')]
518
+ cc_arg = ', '.join([x for x in ([cc] + extras) if x])
519
+
520
+ # gmail_send auto-resolves In-Reply-To/References/subject from the threadId.
521
+ return gmail_send(to=to_addr, subject=hdrs.get('subject', ''), body=body, cc=cc_arg,
522
+ reply_to_id=thread_id, attachments=attachments)
523
+
524
+
525
+ def _html_to_text(html: str) -> str:
526
+ """Convert HTML email to clean readable text."""
527
+ try:
528
+ from bs4 import BeautifulSoup
529
+ soup = BeautifulSoup(html, 'lxml')
530
+
531
+ # Remove script/style elements
532
+ for tag in soup(['script', 'style', 'head', 'meta', 'link']):
533
+ tag.decompose()
534
+
535
+ # Get text and clean up whitespace
536
+ text = soup.get_text(separator='\n')
537
+ lines = [line.strip() for line in text.splitlines()]
538
+ text = '\n'.join(line for line in lines if line)
539
+ return text
540
+ except ImportError:
541
+ # Fallback: basic regex strip
542
+ import re
543
+ text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
544
+ text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
545
+ text = re.sub(r'<[^>]+>', ' ', text)
546
+ text = re.sub(r'\s+', ' ', text)
547
+ return text.strip()
548
+
549
+
550
+ def _extract_body_enhanced(payload: dict, message_id: str = None) -> str:
551
+ """Extract readable text from an email payload.
552
+
553
+ Robust to the things that used to return a blank body: incorrect base64 padding, bodies
554
+ delivered via attachmentId (large/forwarded emails - the #1 cause of "forwarded message is
555
+ blank"), deeply nested multipart, and forwarded message/rfc822 parts. Accumulates ALL text
556
+ parts so forwarded/quoted content is preserved. Prefers text/plain, falls back to HTML->text.
557
+ """
558
+ import base64
559
+
560
+ def _b64(data: str) -> str:
561
+ # Pad to a multiple of 4 with the CORRECT count of '=' (the old '+ ==' hack corrupted some).
562
+ return base64.urlsafe_b64decode(data + '=' * (-len(data) % 4)).decode('utf-8', errors='replace')
563
+
564
+ def _fetch_attachment_body(att_id: str) -> str:
565
+ if not message_id or not att_id:
566
+ return ''
567
+ try:
568
+ r = _gmail_request(
569
+ 'get',
570
+ f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}/attachments/{att_id}'
571
+ )
572
+ if r.status_code == 200:
573
+ return _b64(r.json().get('data', ''))
574
+ except Exception as e:
575
+ logger.debug(f"Body attachment fetch failed: {e}")
576
+ return ''
577
+
578
+ plain_parts, html_parts = [], []
579
+
580
+ def walk(p):
581
+ mime = (p.get('mimeType', '') or '')
582
+ body = p.get('body', {}) or {}
583
+ data = body.get('data', '')
584
+ att_id = body.get('attachmentId', '')
585
+ text = ''
586
+ if data:
587
+ try:
588
+ text = _b64(data)
589
+ except Exception as e:
590
+ logger.debug(f"Body base64 decode failed for {mime}: {e}")
591
+ elif att_id and mime.startswith('text/'):
592
+ # Gmail returns large/forwarded bodies as attachments, not inline data.
593
+ text = _fetch_attachment_body(att_id)
594
+ if text:
595
+ if mime.startswith('text/plain'):
596
+ plain_parts.append(text)
597
+ elif mime.startswith('text/html'):
598
+ html_parts.append(text)
599
+ # Recurse: multipart/* and forwarded message/rfc822 both carry nested parts.
600
+ for part in (p.get('parts', []) or []):
601
+ walk(part)
602
+
603
+ walk(payload)
604
+
605
+ if plain_parts:
606
+ return '\n'.join(plain_parts).strip()
607
+ if html_parts:
608
+ return _html_to_text('\n'.join(html_parts)).strip()
609
+ return ''
610
+
611
+
612
+ @_taint_source("gmail", "google")
613
+ def gmail_read_message_text(message_id: str) -> str:
614
+ """Get full readable text of a message with thread info for replies."""
615
+ msg = gmail_get_message(message_id, format='full')
616
+ if 'error' in msg:
617
+ return f"Error: {msg['error']}"
618
+
619
+ payload = msg.get('payload', {})
620
+ headers = {h['name']: h['value'] for h in payload.get('headers', [])}
621
+ body = _extract_body_enhanced(payload, message_id)
622
+
623
+ # Include IDs needed for replying
624
+ thread_id = msg.get('threadId', '')
625
+ msg_id_header = headers.get('Message-ID', headers.get('Message-Id', ''))
626
+
627
+ return f"""From: {headers.get('From', 'Unknown')}
628
+ To: {headers.get('To', 'Unknown')}
629
+ Date: {headers.get('Date', 'Unknown')}
630
+ Subject: {headers.get('Subject', 'No subject')}
631
+ [Thread-ID: {thread_id}] [Message-ID: {message_id}]
632
+
633
+ {body[:5000]}"""
634
+
635
+
636
+ @_taint_source("gmail", "google")
637
+ def gmail_list_sent(max_results: int = 20) -> dict:
638
+ """List sent emails."""
639
+ return gmail_list_inbox(max_results=max_results, query='in:sent')
640
+
641
+
642
+ @_taint_source("gmail", "google")
643
+ def gmail_list_all(max_results: int = 20, query: str = '') -> dict:
644
+ """List all emails (inbox + sent + all labels).
645
+
646
+ Args:
647
+ max_results: Maximum number of results
648
+ query: Gmail search query (e.g., 'from:someone@example.com', 'subject:hello', 'after:2024/01/01')
649
+ """
650
+ params = {'maxResults': max_results}
651
+ if query:
652
+ params['q'] = query
653
+
654
+ r = _gmail_request(
655
+ 'get',
656
+ 'https://gmail.googleapis.com/gmail/v1/users/me/messages',
657
+ params=params
658
+ )
659
+
660
+ if r.status_code != 200:
661
+ return {'error': f'Gmail list failed: {r.status_code} {r.text[:200]}'}
662
+
663
+ return r.json()
664
+
665
+
666
+ @_taint_source("gmail", "google")
667
+ def gmail_search(query: str, max_results: int = 20) -> list:
668
+ """Search all emails with a query.
669
+
670
+ Query examples:
671
+ 'from:john@example.com' - emails from John
672
+ 'to:jane@example.com' - emails to Jane
673
+ 'subject:meeting' - emails with 'meeting' in subject
674
+ 'in:sent' - sent emails
675
+ 'in:inbox' - inbox emails
676
+ 'is:unread' - unread emails
677
+ 'after:2024/01/01 before:2024/12/31' - date range
678
+ 'has:attachment' - emails with attachments
679
+ 'larger:5M' - emails larger than 5MB
680
+
681
+ Returns list of parsed email summaries.
682
+ """
683
+ result = gmail_list_all(max_results=max_results, query=query)
684
+
685
+ if 'error' in result:
686
+ return [{'error': result['error']}]
687
+
688
+ messages = result.get('messages', [])
689
+ emails = []
690
+
691
+ for msg_ref in messages[:max_results]:
692
+ msg = gmail_get_message(msg_ref['id'], format='metadata')
693
+ if 'error' in msg:
694
+ continue
695
+
696
+ headers = {h['name']: h['value'] for h in msg.get('payload', {}).get('headers', [])}
697
+ labels = msg.get('labelIds', [])
698
+
699
+ emails.append({
700
+ 'id': msg.get('id'),
701
+ 'thread_id': msg.get('threadId'),
702
+ 'from': headers.get('From', ''),
703
+ 'to': headers.get('To', ''),
704
+ 'subject': headers.get('Subject', '(no subject)'),
705
+ 'date': headers.get('Date', ''),
706
+ 'snippet': msg.get('snippet', '')[:100],
707
+ 'labels': labels,
708
+ 'is_sent': 'SENT' in labels,
709
+ 'is_unread': 'UNREAD' in labels,
710
+ })
711
+
712
+ return emails
713
+
714
+
715
+ @_taint_source("gmail", "google")
716
+ def gmail_get_thread(thread_id: str, parse_quotes: bool = True) -> dict:
717
+ """Get all messages in an email thread with enhanced parsing.
718
+
719
+ Args:
720
+ thread_id: The Gmail thread ID
721
+ parse_quotes: If True, separate new content from quoted replies
722
+
723
+ Returns dict with:
724
+ - messages: list of messages in chronological order
725
+ - participants: unique list of all participants
726
+ - subject: thread subject
727
+ - summary: quick overview
728
+ """
729
+ r = _gmail_request(
730
+ 'get',
731
+ f'https://gmail.googleapis.com/gmail/v1/users/me/threads/{thread_id}',
732
+ params={'format': 'full'}
733
+ )
734
+
735
+ if r.status_code != 200:
736
+ return {'error': f'Thread fetch failed: {r.status_code}', 'messages': []}
737
+
738
+ thread = r.json()
739
+ messages = []
740
+ participants = set()
741
+ subject = ''
742
+
743
+ for msg in thread.get('messages', []):
744
+ payload = msg.get('payload', {})
745
+ hdrs = {h['name']: h['value'] for h in payload.get('headers', [])}
746
+
747
+ # Track participants
748
+ from_addr = hdrs.get('From', '')
749
+ to_addr = hdrs.get('To', '')
750
+ participants.add(from_addr)
751
+ if to_addr:
752
+ for addr in to_addr.split(','):
753
+ participants.add(addr.strip())
754
+
755
+ # Get subject from first message
756
+ if not subject:
757
+ subject = hdrs.get('Subject', '')
758
+
759
+ # Extract body with HTML support
760
+ body = _extract_body_enhanced(payload, msg.get('id'))
761
+ labels = msg.get('labelIds', [])
762
+
763
+ # Parse quotes if requested
764
+ if parse_quotes and body:
765
+ parsed = parse_email_quotes(body)
766
+ new_content = parsed['new_content']
767
+ quoted = parsed['quoted']
768
+ else:
769
+ new_content = body
770
+ quoted = []
771
+
772
+ # Check for attachments
773
+ attachments = gmail_list_attachments(msg.get('id', ''))
774
+ has_attachments = bool(attachments and 'error' not in attachments[0])
775
+
776
+ messages.append({
777
+ 'id': msg.get('id'),
778
+ 'from': from_addr,
779
+ 'to': to_addr,
780
+ 'cc': hdrs.get('Cc', ''),
781
+ 'date': hdrs.get('Date', ''),
782
+ 'subject': hdrs.get('Subject', ''),
783
+ 'new_content': new_content[:3000] if parse_quotes else None,
784
+ 'body': body[:3000] if not parse_quotes else None,
785
+ 'quoted_sections': len(quoted),
786
+ 'is_sent': 'SENT' in labels,
787
+ 'has_attachments': has_attachments,
788
+ 'attachment_count': len(attachments) if has_attachments else 0,
789
+ })
790
+
791
+ return {
792
+ 'thread_id': thread_id,
793
+ 'subject': subject,
794
+ 'message_count': len(messages),
795
+ 'participants': list(participants),
796
+ 'messages': messages,
797
+ }
798
+
799
+
800
+ @_taint_source("gmail", "google")
801
+ def gmail_get_recent_activity(hours: int = 24, max_results: int = 50) -> dict:
802
+ """Get recent email activity (sent and received) in the last N hours.
803
+
804
+ Returns summary of recent emails grouped by sent/received.
805
+ """
806
+ from datetime import datetime, timedelta
807
+
808
+ # Calculate date for query
809
+ after_date = (datetime.now() - timedelta(hours=hours)).strftime('%Y/%m/%d')
810
+
811
+ # Get all recent emails
812
+ all_emails = gmail_search(f'after:{after_date}', max_results=max_results)
813
+
814
+ if all_emails and 'error' in all_emails[0]:
815
+ return {'error': all_emails[0]['error']}
816
+
817
+ sent = [e for e in all_emails if e.get('is_sent')]
818
+ received = [e for e in all_emails if not e.get('is_sent')]
819
+
820
+ return {
821
+ 'period_hours': hours,
822
+ 'total': len(all_emails),
823
+ 'sent_count': len(sent),
824
+ 'received_count': len(received),
825
+ 'sent': sent[:10], # Last 10 sent
826
+ 'received': received[:10], # Last 10 received
827
+ }
828
+
829
+
830
+ # ─────────────────────────────────────────────
831
+ # EMAIL ATTACHMENTS
832
+ # ─────────────────────────────────────────────
833
+
834
+ DOWNLOADS_DIR = Path.home() / '.hyperclaw' / 'downloads'
835
+ DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
836
+
837
+
838
+ @_taint_source("gmail", "google")
839
+ def gmail_list_attachments(message_id: str) -> list:
840
+ """List all attachments in an email.
841
+
842
+ Returns list of {filename, mime_type, size, attachment_id, part_id}
843
+ """
844
+ msg = gmail_get_message(message_id, format='full')
845
+ if 'error' in msg:
846
+ return [{'error': msg['error']}]
847
+
848
+ attachments = []
849
+
850
+ def find_attachments(part, part_path=''):
851
+ filename = part.get('filename', '')
852
+ attachment_id = part.get('body', {}).get('attachmentId')
853
+
854
+ if filename and attachment_id:
855
+ attachments.append({
856
+ 'filename': filename,
857
+ 'mime_type': part.get('mimeType', 'application/octet-stream'),
858
+ 'size': part.get('body', {}).get('size', 0),
859
+ 'attachment_id': attachment_id,
860
+ 'part_id': part_path,
861
+ })
862
+
863
+ for i, sub_part in enumerate(part.get('parts', [])):
864
+ find_attachments(sub_part, f"{part_path}.{i}" if part_path else str(i))
865
+
866
+ find_attachments(msg.get('payload', {}))
867
+ return attachments
868
+
869
+
870
+ @_taint_source("gmail", "google")
871
+ def gmail_download_attachment(message_id: str, attachment_id: str, filename: str = None) -> dict:
872
+ """Download an email attachment.
873
+
874
+ Args:
875
+ message_id: The email message ID
876
+ attachment_id: The attachment ID from gmail_list_attachments()
877
+ filename: Optional filename to save as (defaults to original)
878
+
879
+ Returns: {path, size, filename} on success or {error} on failure
880
+ """
881
+ import base64
882
+
883
+ r = _gmail_request(
884
+ 'get',
885
+ f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}/attachments/{attachment_id}',
886
+ )
887
+
888
+ if r.status_code != 200:
889
+ return {'error': f'Download failed: {r.status_code} {r.text[:200]}'}
890
+
891
+ data = r.json().get('data', '')
892
+ if not data:
893
+ return {'error': 'No attachment data returned'}
894
+
895
+ # Decode base64
896
+ file_data = base64.urlsafe_b64decode(data + '==')
897
+
898
+ # Determine filename
899
+ if not filename:
900
+ # Try to get from attachment list
901
+ attachments = gmail_list_attachments(message_id)
902
+ for att in attachments:
903
+ if att.get('attachment_id') == attachment_id:
904
+ filename = att.get('filename', f'attachment_{attachment_id[:8]}')
905
+ break
906
+ else:
907
+ filename = f'attachment_{attachment_id[:8]}'
908
+
909
+ # Save file
910
+ save_path = DOWNLOADS_DIR / filename
911
+ save_path.write_bytes(file_data)
912
+
913
+ return {
914
+ 'path': str(save_path),
915
+ 'size': len(file_data),
916
+ 'filename': filename,
917
+ }
918
+
919
+
920
+ @_taint_source("gmail", "google")
921
+ def gmail_download_all_attachments(message_id: str) -> list:
922
+ """Download all attachments from an email.
923
+
924
+ Returns list of download results.
925
+ """
926
+ attachments = gmail_list_attachments(message_id)
927
+ if attachments and 'error' in attachments[0]:
928
+ return attachments
929
+
930
+ results = []
931
+ for att in attachments:
932
+ result = gmail_download_attachment(
933
+ message_id,
934
+ att['attachment_id'],
935
+ att['filename']
936
+ )
937
+ result['original_filename'] = att['filename']
938
+ results.append(result)
939
+
940
+ return results
941
+
942
+
943
+
944
+ def gmail_forward(message_id: str, to: str, note: str = '', include_attachments: bool = True) -> dict:
945
+ """Forward an email (body + attachments) to someone, with an optional note on top."""
946
+ meta = gmail_get_message(message_id)
947
+ if 'error' in meta:
948
+ return meta
949
+ headers = {h['name'].lower(): h['value'] for h in meta.get('payload', {}).get('headers', [])}
950
+ orig_subject = headers.get('subject', '(no subject)')
951
+ orig_from = headers.get('from', 'unknown')
952
+ orig_date = headers.get('date', '')
953
+ body_text = gmail_read_message_text(message_id)
954
+
955
+ fwd_body = ''
956
+ if note:
957
+ fwd_body += note + '\n\n'
958
+ fwd_body += f"---------- Forwarded message ----------\nFrom: {orig_from}\nDate: {orig_date}\nSubject: {orig_subject}\n\n{body_text}"
959
+
960
+ att_paths = []
961
+ if include_attachments:
962
+ try:
963
+ for saved in gmail_download_all_attachments(message_id):
964
+ path = saved.get('path') if isinstance(saved, dict) else saved
965
+ if path:
966
+ att_paths.append(path)
967
+ except Exception as e:
968
+ logger.warning(f"Forward: attachment download failed: {e}")
969
+
970
+ subject = orig_subject if orig_subject.lower().startswith('fwd:') else f'Fwd: {orig_subject}'
971
+ return gmail_send(to=to, subject=subject, body=fwd_body,
972
+ attachments=att_paths or None, allow_unattached=True)
973
+
974
+
975
+ def gmail_create_draft(to: str, subject: str, body: str, cc: str = '',
976
+ attachments: list = None, body_html: str = None,
977
+ thread_id: str = '') -> dict:
978
+ """Create a Gmail DRAFT (visible in the account's Drafts folder) instead of sending.
979
+ The approval-queue path: the assistant prepares, the owner hits send."""
980
+ from email.mime.text import MIMEText
981
+ from email.mime.multipart import MIMEMultipart
982
+ from email.mime.base import MIMEBase
983
+ from email import encoders
984
+ import mimetypes
985
+ import base64
986
+
987
+ token = GoogleTokenManager.get_access_token()
988
+ if not token:
989
+ return {'error': 'Gmail auth unavailable'}
990
+
991
+ msg = MIMEMultipart('mixed') if (attachments or body_html) else MIMEMultipart('alternative')
992
+ msg['to'] = to
993
+ msg['subject'] = subject
994
+ if cc:
995
+ msg['cc'] = cc
996
+ if body_html:
997
+ alt = MIMEMultipart('alternative')
998
+ alt.attach(MIMEText(body, 'plain'))
999
+ alt.attach(MIMEText(body_html, 'html'))
1000
+ msg.attach(alt)
1001
+ else:
1002
+ msg.attach(MIMEText(body, 'plain'))
1003
+ for file_path in (attachments or []):
1004
+ path = Path(file_path)
1005
+ if not path.exists():
1006
+ return {'error': f'Attachment not found: {file_path}'}
1007
+ mime_type, _ = mimetypes.guess_type(str(path))
1008
+ main_type, sub_type = (mime_type or 'application/octet-stream').split('/', 1)
1009
+ part = MIMEBase(main_type, sub_type)
1010
+ part.set_payload(path.read_bytes())
1011
+ encoders.encode_base64(part)
1012
+ part.add_header('Content-Disposition', 'attachment', filename=path.name)
1013
+ msg.attach(part)
1014
+
1015
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
1016
+ payload = {'message': {'raw': raw}}
1017
+ if thread_id:
1018
+ payload['message']['threadId'] = thread_id
1019
+ r = _gmail_request('post', 'https://gmail.googleapis.com/gmail/v1/users/me/drafts', json=payload)
1020
+ if r.status_code in (200, 201):
1021
+ j = r.json()
1022
+ return {'status': 'draft_created', 'draft_id': j.get('id', ''), 'to': to, 'subject': subject,
1023
+ 'note': 'Draft is in the Drafts folder awaiting review/send.'}
1024
+ return {'error': f'Draft create failed: {r.status_code} {r.text[:200]}'}
1025
+
1026
+
1027
+ def gmail_modify(message_id: str, mark_read: bool = None, archive: bool = None,
1028
+ star: bool = None, add_labels: list = None, remove_labels: list = None) -> dict:
1029
+ """Inbox management: mark read/unread, archive, star/unstar, arbitrary labels."""
1030
+ add, remove = list(add_labels or []), list(remove_labels or [])
1031
+ if mark_read is True:
1032
+ remove.append('UNREAD')
1033
+ elif mark_read is False:
1034
+ add.append('UNREAD')
1035
+ if archive is True:
1036
+ remove.append('INBOX')
1037
+ elif archive is False:
1038
+ add.append('INBOX')
1039
+ if star is True:
1040
+ add.append('STARRED')
1041
+ elif star is False:
1042
+ remove.append('STARRED')
1043
+ if not add and not remove:
1044
+ return {'error': 'Nothing to modify'}
1045
+ r = _gmail_request(
1046
+ 'post', f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}/modify',
1047
+ json={'addLabelIds': add, 'removeLabelIds': remove})
1048
+ if r.status_code == 200:
1049
+ return {'status': 'modified', 'added': add, 'removed': remove}
1050
+ return {'error': f'Modify failed: {r.status_code} {r.text[:200]}'}
1051
+
1052
+ def strip_email_signature(body: str) -> dict:
1053
+ """Detect and strip email signature from body.
1054
+
1055
+ Returns: {content: str, signature: str}
1056
+ """
1057
+ import re
1058
+
1059
+ # Common signature patterns
1060
+ sig_patterns = [
1061
+ r'\n--\s*\n', # Standard -- delimiter
1062
+ r'\nBest,?\s*\n', # Best,
1063
+ r'\nRegards,?\s*\n', # Regards,
1064
+ r'\nThanks,?\s*\n', # Thanks,
1065
+ r'\nCheers,?\s*\n', # Cheers,
1066
+ r'\nSincerely,?\s*\n', # Sincerely,
1067
+ r'\nSent from my iPhone', # iOS
1068
+ r'\nSent from my iPad', # iPad
1069
+ r'\nGet Outlook for', # Outlook mobile
1070
+ r'\n_{3,}', # ___ separator
1071
+ r'\n-{3,}', # --- separator
1072
+ ]
1073
+
1074
+ earliest_match = len(body)
1075
+ matched_pattern = None
1076
+
1077
+ for pattern in sig_patterns:
1078
+ match = re.search(pattern, body, re.IGNORECASE)
1079
+ if match and match.start() < earliest_match:
1080
+ earliest_match = match.start()
1081
+ matched_pattern = pattern
1082
+
1083
+ if matched_pattern and earliest_match < len(body) - 50:
1084
+ return {
1085
+ 'content': body[:earliest_match].strip(),
1086
+ 'signature': body[earliest_match:].strip()
1087
+ }
1088
+
1089
+ return {'content': body.strip(), 'signature': ''}
1090
+
1091
+
1092
+ def parse_email_quotes(body: str) -> dict:
1093
+ """Parse email body to separate new content from quoted replies.
1094
+
1095
+ Returns: {new_content: str, quoted: list[{from, content}]}
1096
+ """
1097
+ import re
1098
+
1099
+ lines = body.split('\n')
1100
+ new_content = []
1101
+ quoted_sections = []
1102
+ current_quote = []
1103
+ quote_attribution = ''
1104
+ in_quote = False
1105
+
1106
+ for line in lines:
1107
+ # Check for quote attribution line
1108
+ quote_start = re.match(r'^On .+ wrote:$', line.strip())
1109
+ if quote_start:
1110
+ if current_quote:
1111
+ quoted_sections.append({
1112
+ 'from': quote_attribution,
1113
+ 'content': '\n'.join(current_quote)
1114
+ })
1115
+ quote_attribution = line.strip()
1116
+ current_quote = []
1117
+ in_quote = True
1118
+ continue
1119
+
1120
+ # Check for quoted line (starts with >)
1121
+ if line.strip().startswith('>'):
1122
+ in_quote = True
1123
+ current_quote.append(line.lstrip('> ').strip())
1124
+ elif in_quote and line.strip() == '':
1125
+ current_quote.append('')
1126
+ elif in_quote:
1127
+ # End of quoted section
1128
+ if current_quote:
1129
+ quoted_sections.append({
1130
+ 'from': quote_attribution,
1131
+ 'content': '\n'.join(current_quote)
1132
+ })
1133
+ current_quote = []
1134
+ in_quote = False
1135
+ quote_attribution = ''
1136
+ new_content.append(line)
1137
+ else:
1138
+ new_content.append(line)
1139
+
1140
+ # Handle remaining quote
1141
+ if current_quote:
1142
+ quoted_sections.append({
1143
+ 'from': quote_attribution,
1144
+ 'content': '\n'.join(current_quote)
1145
+ })
1146
+
1147
+ return {
1148
+ 'new_content': '\n'.join(new_content).strip(),
1149
+ 'quoted': quoted_sections
1150
+ }
1151
+
1152
+
1153
+ # ─────────────────────────────────────────────
1154
+ # CALENDAR
1155
+ # ─────────────────────────────────────────────
1156
+
1157
+ @_taint_source("google_calendar", "google")
1158
+ def calendar_get_events(days_ahead: int = 7) -> list:
1159
+ """Get upcoming calendar events."""
1160
+ now = datetime.now(timezone.utc)
1161
+ end = now + timedelta(days=days_ahead)
1162
+
1163
+ r = _gmail_request(
1164
+ 'get',
1165
+ 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
1166
+ params={
1167
+ 'timeMin': now.isoformat(),
1168
+ 'timeMax': end.isoformat(),
1169
+ 'maxResults': 25,
1170
+ 'singleEvents': True,
1171
+ 'orderBy': 'startTime'
1172
+ }
1173
+ )
1174
+
1175
+ if r.status_code != 200:
1176
+ return [{'error': f'Calendar failed: {r.status_code}'}]
1177
+
1178
+ events = r.json().get('items', [])
1179
+ result = []
1180
+ for e in events:
1181
+ start = e.get('start', {})
1182
+ result.append({
1183
+ 'summary': e.get('summary', 'Untitled'),
1184
+ 'start': start.get('dateTime', start.get('date', '')),
1185
+ 'end': e.get('end', {}).get('dateTime', ''),
1186
+ 'location': e.get('location', ''),
1187
+ 'description': e.get('description', '')[:200],
1188
+ 'attendees': [a.get('email') for a in e.get('attendees', [])],
1189
+ })
1190
+ return result
1191
+
1192
+
1193
+ def calendar_create_event(title: str, start_iso: str, end_iso: str,
1194
+ description: str = '', attendees: list = None) -> dict:
1195
+ """Create a calendar event."""
1196
+ body = {
1197
+ 'summary': title,
1198
+ 'start': {'dateTime': start_iso, 'timeZone': 'America/Los_Angeles'},
1199
+ 'end': {'dateTime': end_iso, 'timeZone': 'America/Los_Angeles'},
1200
+ 'description': description,
1201
+ }
1202
+ if attendees:
1203
+ body['attendees'] = [{'email': a} for a in attendees]
1204
+
1205
+ r = _gmail_request(
1206
+ 'post',
1207
+ 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
1208
+ headers={'Content-Type': 'application/json'},
1209
+ json=body
1210
+ )
1211
+
1212
+ if r.status_code in (200, 201):
1213
+ event = r.json()
1214
+ return {'status': 'created', 'id': event.get('id'), 'link': event.get('htmlLink')}
1215
+ return {'error': f'Create failed: {r.status_code} {r.text[:200]}'}
1216
+
1217
+
1218
+ # ─────────────────────────────────────────────
1219
+ # iMESSAGE (Native AppleScript)
1220
+ # ─────────────────────────────────────────────
1221
+
1222
+ def imessage_send(recipient: str, message: str) -> dict:
1223
+ """Send an iMessage via AppleScript."""
1224
+ if sys.platform != 'darwin':
1225
+ return {'error': 'iMessage requires macOS'}
1226
+
1227
+ # Escape for AppleScript
1228
+ content = message.replace('\\', '\\\\').replace('"', '\\"')
1229
+ recipient_clean = recipient.replace('"', '\\"')
1230
+
1231
+ script = f'''
1232
+ tell application "Messages"
1233
+ set targetService to first service whose service type = iMessage
1234
+ set targetBuddy to buddy "{recipient_clean}" of targetService
1235
+ send "{content}" to targetBuddy
1236
+ end tell
1237
+ '''
1238
+
1239
+ try:
1240
+ result = subprocess.run(
1241
+ ['osascript', '-e', script],
1242
+ capture_output=True, text=True, timeout=15
1243
+ )
1244
+ if result.returncode == 0:
1245
+ return {'status': 'sent', 'recipient': recipient}
1246
+ else:
1247
+ # Try SMS fallback
1248
+ script_sms = f'''
1249
+ tell application "Messages"
1250
+ set targetService to first service whose service type = SMS
1251
+ set targetBuddy to buddy "{recipient_clean}" of targetService
1252
+ send "{content}" to targetBuddy
1253
+ end tell
1254
+ '''
1255
+ result2 = subprocess.run(
1256
+ ['osascript', '-e', script_sms],
1257
+ capture_output=True, text=True, timeout=15
1258
+ )
1259
+ if result2.returncode == 0:
1260
+ return {'status': 'sent_sms', 'recipient': recipient}
1261
+ return {'error': f'AppleScript error: {result.stderr.strip()}'}
1262
+ except subprocess.TimeoutExpired:
1263
+ return {'error': 'iMessage send timed out'}
1264
+ except Exception as e:
1265
+ return {'error': str(e)}
1266
+
1267
+
1268
+ @_taint_source("imessage", "imessage")
1269
+ def imessage_get_recent(contact: str = '', limit: int = 10) -> list:
1270
+ """Get recent iMessages (requires Full Disk Access for Messages DB)."""
1271
+ db_path = Path.home() / 'Library/Messages/chat.db'
1272
+
1273
+ if not db_path.exists():
1274
+ return [{'error': 'Messages DB not accessible — Full Disk Access required'}]
1275
+
1276
+ try:
1277
+ import sqlite3
1278
+ conn = sqlite3.connect(str(db_path))
1279
+ conn.row_factory = sqlite3.Row
1280
+
1281
+ query = '''
1282
+ SELECT
1283
+ m.rowid,
1284
+ m.text,
1285
+ m.date / 1000000000 + 978307200 as unix_time,
1286
+ m.is_from_me,
1287
+ h.id as handle
1288
+ FROM message m
1289
+ LEFT JOIN handle h ON m.handle_id = h.rowid
1290
+ WHERE m.text IS NOT NULL
1291
+ '''
1292
+
1293
+ if contact:
1294
+ query += f" AND h.id LIKE '%{contact}%'"
1295
+
1296
+ query += f" ORDER BY m.date DESC LIMIT {limit}"
1297
+
1298
+ rows = conn.execute(query).fetchall()
1299
+ conn.close()
1300
+
1301
+ messages = []
1302
+ for row in rows:
1303
+ messages.append({
1304
+ 'text': row['text'],
1305
+ 'from_me': bool(row['is_from_me']),
1306
+ 'handle': row['handle'] or 'Unknown',
1307
+ 'time': datetime.fromtimestamp(row['unix_time']).strftime('%Y-%m-%d %H:%M'),
1308
+ })
1309
+ return messages
1310
+ except Exception as e:
1311
+ return [{'error': f'DB read failed: {e}'}]
1312
+
1313
+
1314
+ # ─────────────────────────────────────────────
1315
+ # SUPABASE
1316
+ # ─────────────────────────────────────────────
1317
+
1318
+ SUPABASE_URL = os.getenv('SUPABASE_URL', '')
1319
+ SUPABASE_KEY = os.getenv('SUPABASE_SERVICE_KEY', '')
1320
+
1321
+ def supabase_query(table: str, limit: int = 50, filters: dict = None) -> list:
1322
+ """Query a Supabase table."""
1323
+ headers = {
1324
+ 'apikey': SUPABASE_KEY,
1325
+ 'Authorization': f'Bearer {SUPABASE_KEY}',
1326
+ }
1327
+ params = {'limit': limit}
1328
+ if filters:
1329
+ for k, v in filters.items():
1330
+ params[f'eq.{k}'] = v
1331
+
1332
+ with httpx.Client(timeout=30) as client:
1333
+ r = client.get(f'{SUPABASE_URL}/rest/v1/{table}', headers=headers, params=params)
1334
+
1335
+ return r.json() if r.status_code == 200 else [{'error': r.text[:200]}]
1336
+
1337
+
1338
+ def supabase_insert(table: str, data: dict) -> dict:
1339
+ """Insert a row into a Supabase table."""
1340
+ headers = {
1341
+ 'apikey': SUPABASE_KEY,
1342
+ 'Authorization': f'Bearer {SUPABASE_KEY}',
1343
+ 'Content-Type': 'application/json',
1344
+ 'Prefer': 'return=representation'
1345
+ }
1346
+
1347
+ with httpx.Client(timeout=30) as client:
1348
+ r = client.post(f'{SUPABASE_URL}/rest/v1/{table}', headers=headers, json=data)
1349
+
1350
+ if r.status_code in (200, 201):
1351
+ return {'status': 'inserted', 'data': r.json()}
1352
+ return {'error': f'{r.status_code} {r.text[:200]}'}
1353
+
1354
+
1355
+ @training_sink("integrations.supabase_store_memory", own_words=True)
1356
+ def supabase_store_memory(content: str, memory_type: str = 'episodic',
1357
+ tags: list = None, importance: float = 0.5) -> dict:
1358
+ """Store a memory in Supabase episodic_memories table."""
1359
+ return supabase_insert('episodic_memories', {
1360
+ 'content': content,
1361
+ 'memory_type': memory_type,
1362
+ 'tags': tags or [],
1363
+ 'importance': importance,
1364
+ 'created_at': datetime.now(timezone.utc).isoformat(),
1365
+ })
1366
+
1367
+
1368
+ # ─────────────────────────────────────────────
1369
+ # INTEGRATION STATUS
1370
+ # ─────────────────────────────────────────────
1371
+
1372
+ def get_integration_status() -> dict:
1373
+ """Get status of all integrations."""
1374
+ status = {}
1375
+
1376
+ # Gmail / Google
1377
+ try:
1378
+ token = GoogleTokenManager.get_access_token()
1379
+ status['gmail'] = 'connected' if token else 'no token'
1380
+ status['calendar'] = 'connected' if token else 'no token'
1381
+ except Exception as e:
1382
+ status['gmail'] = f'error: {e}'
1383
+ status['calendar'] = f'error: {e}'
1384
+
1385
+ # iMessage
1386
+ status['imessage'] = 'native AppleScript (macOS)' if sys.platform == 'darwin' else 'not macOS'
1387
+
1388
+ # Supabase
1389
+ try:
1390
+ with httpx.Client(timeout=10) as client:
1391
+ r = client.get(
1392
+ f'{SUPABASE_URL}/rest/v1/episodic_memories?limit=1',
1393
+ headers={'apikey': SUPABASE_KEY, 'Authorization': f'Bearer {SUPABASE_KEY}'}
1394
+ )
1395
+ status['supabase'] = 'connected' if r.status_code == 200 else f'error: {r.status_code}'
1396
+ except Exception as e:
1397
+ status['supabase'] = f'error: {e}'
1398
+
1399
+ # Telegram (check .env)
1400
+ tg_token = os.getenv('TELEGRAM_BOT_TOKEN', '')
1401
+ status['telegram'] = 'configured' if tg_token else 'no token'
1402
+
1403
+ # WhatsApp
1404
+ wa_token = os.getenv('WHATSAPP_ACCESS_TOKEN', '')
1405
+ status['whatsapp'] = 'configured' if wa_token else 'credentials needed'
1406
+
1407
+ return status
1408
+
1409
+
1410
+ if __name__ == '__main__':
1411
+ print("Integration Status Check:")
1412
+ status = get_integration_status()
1413
+ for k, v in status.items():
1414
+ print(f" {k:15} {v}")
1415
+
1416
+ print("\nCalendar Events (next 7 days):")
1417
+ events = calendar_get_events(7)
1418
+ if events:
1419
+ for e in events:
1420
+ print(f" - {e.get('summary')} @ {e.get('start')}")
1421
+ else:
1422
+ print(" (none)")
1423
+
1424
+ print("\nGmail Inbox (5 most recent):")
1425
+ inbox = gmail_list_inbox(5)
1426
+ msgs = inbox.get('messages', [])
1427
+ print(f" {len(msgs)} messages retrieved")