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,397 @@
1
+ """Google sign-in for the Workspace tools: one sign-in per person, in their own
2
+ browser, through the team's INTERNAL Google app (installed-app OAuth,
3
+ loopback redirect, PKCE).
4
+
5
+ - The OAuth client file (Desktop app, ``{"installed": {...}}``) arrives in
6
+ each person's team profile and is never part of the package or the repo:
7
+ ``HYPERCLAW_GOOGLE_CLIENT_FILE``, else ``$HYPERCLAW_ROOT/profile/google-client.json``.
8
+ A client file inside the installed package is refused.
9
+ - Scopes (Vaughn, 12 Oct): Gmail read, compose and send; read-only Calendar,
10
+ Meet (records and transcripts), Drive, Docs, Sheets and Slides. Calendar
11
+ write (``calendar.events``) is asked for only when Hyper's managed config
12
+ sets ``"google_calendar_write": true``. A grant with any other scope is
13
+ refused and nothing is stored; a grant missing some scopes is kept, and
14
+ the tools that need a missing scope say so.
15
+ - The token lives in the OS secret store when one is available (``keyring``),
16
+ else in ``$HYPERCLAW_ROOT/google/token.json`` (0600, directory 0700). Either
17
+ way it is keyed by this install's ``HYPERCLAW_ROOT``: every person's agent
18
+ has its own token and there is no shared store, so nothing passes between
19
+ people's agents. The token is never printed, logged or sent to a model.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import logging
26
+ import os
27
+ import secrets
28
+ import stat
29
+ import time
30
+ from pathlib import Path
31
+ from typing import Callable, Dict, List, Optional
32
+ from urllib.parse import urlencode, urlsplit
33
+
34
+ import httpx
35
+
36
+ from .errors import GoogleError, Reason
37
+
38
+ log = logging.getLogger("hyperclaw.google")
39
+
40
+ _G = "https://www.googleapis.com/auth/"
41
+ GMAIL_READ = _G + "gmail.readonly"
42
+ GMAIL_COMPOSE = _G + "gmail.compose"
43
+ GMAIL_SEND = _G + "gmail.send"
44
+ CALENDAR_READ = _G + "calendar.readonly"
45
+ CALENDAR_WRITE = _G + "calendar.events"
46
+ MEET_READ = _G + "meetings.space.readonly"
47
+ DRIVE_READ = _G + "drive.readonly"
48
+ DOCS_READ = _G + "documents.readonly"
49
+ SHEETS_READ = _G + "spreadsheets.readonly"
50
+ SLIDES_READ = _G + "presentations.readonly"
51
+
52
+ SCOPES = (GMAIL_READ, GMAIL_COMPOSE, GMAIL_SEND, CALENDAR_READ, MEET_READ, DRIVE_READ,
53
+ DOCS_READ, SHEETS_READ, SLIDES_READ)
54
+ OPTIONAL_SCOPES = (CALENDAR_WRITE,)
55
+
56
+ AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
57
+ TOKEN_URI = "https://oauth2.googleapis.com/token"
58
+ TIMEOUT = 20.0
59
+ CONSENT_WAIT = 300.0
60
+ KEYRING_SERVICE = "HyperAgents Google"
61
+ STORE_ENV = "HYPERCLAW_GOOGLE_TOKEN_STORE" # "file" forces the file store
62
+ CLIENT_ENV = "HYPERCLAW_GOOGLE_CLIENT_FILE"
63
+
64
+
65
+ def _root() -> Path:
66
+ return Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
67
+
68
+
69
+ def _loopback(url: str) -> bool:
70
+ return (urlsplit(url).hostname or "") in ("127.0.0.1", "localhost", "::1")
71
+
72
+
73
+ def token_uri() -> str:
74
+ """Google's token endpoint. A loopback override exists for tests only."""
75
+ o = os.environ.get("HYPERCLAW_GOOGLE_TOKEN_URI", "").strip()
76
+ return o if o and _loopback(o) else TOKEN_URI
77
+
78
+
79
+ # ───────────────────────── scopes ─────────────────────────
80
+
81
+ def calendar_write_allowed() -> bool:
82
+ """Hyper's managed config turns on Calendar write (off by default)."""
83
+ try:
84
+ from hyperclaw import managed_config
85
+ data, _ok = managed_config.raw() # system file, else the verified team profile
86
+ return isinstance(data, dict) and data.get("google_calendar_write") is True
87
+ except Exception:
88
+ return False
89
+
90
+
91
+ def requested_scopes() -> tuple:
92
+ return SCOPES + (OPTIONAL_SCOPES if calendar_write_allowed() else ())
93
+
94
+
95
+ def check_scopes(granted: str) -> List[str]:
96
+ """The granted scopes, or GoogleError when any was not asked for."""
97
+ got = sorted(set((granted or "").split()))
98
+ allowed = set(requested_scopes())
99
+ extra = [s for s in got if s not in allowed]
100
+ if extra:
101
+ raise GoogleError(Reason.EXTRA_SCOPE, f"{len(extra)} extra")
102
+ if not got:
103
+ raise GoogleError(Reason.CONSENT, "no scopes granted")
104
+ return got
105
+
106
+
107
+ # ───────────────────────── the client file ─────────────────────────
108
+
109
+ def client_path() -> Path:
110
+ explicit = os.environ.get(CLIENT_ENV, "").strip()
111
+ if explicit:
112
+ return Path(explicit).expanduser()
113
+ return _root() / "profile" / "google-client.json"
114
+
115
+
116
+ def _inside_package(p: Path) -> bool:
117
+ pkg = Path(__file__).resolve().parents[2]
118
+ try:
119
+ p.resolve().relative_to(pkg)
120
+ return (pkg / "hyperclaw").is_dir()
121
+ except ValueError:
122
+ return False
123
+
124
+
125
+ def load_client(path: Optional[Path] = None) -> Dict[str, str]:
126
+ p = Path(path) if path else client_path()
127
+ if not p.is_file():
128
+ raise GoogleError(Reason.CLIENT_FILE, "missing")
129
+ if _inside_package(p):
130
+ raise GoogleError(Reason.CLIENT_FILE, "inside the package")
131
+ try:
132
+ data = json.loads(p.read_text(encoding="utf-8"))
133
+ except Exception:
134
+ raise GoogleError(Reason.CLIENT_FILE, "unparseable")
135
+ inst = data.get("installed") if isinstance(data, dict) else None
136
+ if not isinstance(inst, dict) or not inst.get("client_id") or not inst.get("client_secret"):
137
+ raise GoogleError(Reason.CLIENT_FILE, "not a desktop client")
138
+ return {"client_id": str(inst["client_id"]), "client_secret": str(inst["client_secret"])}
139
+
140
+
141
+ # ───────────────────────── token storage (per install) ─────────────────────────
142
+
143
+ def install_key() -> str:
144
+ """This install's identity for the secret store: a hash of its root."""
145
+ return hashlib.sha256(str(_root().resolve()).encode()).hexdigest()[:24]
146
+
147
+
148
+ def token_file() -> Path:
149
+ return _root() / "google" / "token.json"
150
+
151
+
152
+ def _keyring():
153
+ if os.environ.get(STORE_ENV, "").strip().lower() == "file":
154
+ return None
155
+ try:
156
+ import keyring # optional: the OS secret store
157
+ from keyring.backends import fail
158
+ kr = keyring.get_keyring()
159
+ if isinstance(kr, fail.Keyring) or "fail" in type(kr).__module__:
160
+ return None
161
+ return keyring
162
+ except Exception:
163
+ return None
164
+
165
+
166
+ def store_name() -> str:
167
+ return "os-secret-store" if _keyring() is not None else "file"
168
+
169
+
170
+ def _too_open(p: Path) -> bool:
171
+ try:
172
+ return bool(p.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO))
173
+ except FileNotFoundError:
174
+ return False
175
+
176
+
177
+ def load_token() -> dict:
178
+ kr = _keyring()
179
+ if kr is not None:
180
+ try:
181
+ raw = kr.get_password(KEYRING_SERVICE, install_key())
182
+ except Exception:
183
+ raw = None
184
+ if raw:
185
+ try:
186
+ d = json.loads(raw)
187
+ return d if isinstance(d, dict) else {}
188
+ except ValueError:
189
+ return {}
190
+ p = token_file()
191
+ if not p.exists():
192
+ return {}
193
+ if _too_open(p):
194
+ raise GoogleError(Reason.TOKEN_FILE_OPEN)
195
+ try:
196
+ d = json.loads(p.read_text(encoding="utf-8"))
197
+ return d if isinstance(d, dict) else {}
198
+ except Exception:
199
+ return {}
200
+
201
+
202
+ def save_token(tok: dict) -> str:
203
+ """Store the token; returns where ("os-secret-store" | "file")."""
204
+ text = json.dumps(tok)
205
+ kr = _keyring()
206
+ if kr is not None:
207
+ try:
208
+ kr.set_password(KEYRING_SERVICE, install_key(), text)
209
+ _remove_file()
210
+ return "os-secret-store"
211
+ except Exception:
212
+ log.warning("google: OS secret store unavailable; using the private file")
213
+ p = token_file()
214
+ p.parent.mkdir(parents=True, exist_ok=True)
215
+ try:
216
+ os.chmod(p.parent, 0o700)
217
+ except OSError:
218
+ pass
219
+ tmp = p.with_name(p.name + ".tmp")
220
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
221
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
222
+ f.write(text)
223
+ os.chmod(tmp, 0o600)
224
+ os.replace(tmp, p)
225
+ return "file"
226
+
227
+
228
+ def _remove_file() -> None:
229
+ try:
230
+ token_file().unlink()
231
+ except FileNotFoundError:
232
+ pass
233
+
234
+
235
+ def disconnect() -> bool:
236
+ had = False
237
+ kr = _keyring()
238
+ if kr is not None:
239
+ try:
240
+ if kr.get_password(KEYRING_SERVICE, install_key()):
241
+ kr.delete_password(KEYRING_SERVICE, install_key())
242
+ had = True
243
+ except Exception:
244
+ pass
245
+ if token_file().exists():
246
+ _remove_file()
247
+ had = True
248
+ return had
249
+
250
+
251
+ def connected() -> bool:
252
+ try:
253
+ return bool(load_token().get("refresh_token"))
254
+ except GoogleError:
255
+ return False
256
+
257
+
258
+ def granted_scopes() -> List[str]:
259
+ try:
260
+ return list(load_token().get("scopes") or [])
261
+ except GoogleError:
262
+ return []
263
+
264
+
265
+ def has_scope(scope: str) -> bool:
266
+ return scope in granted_scopes()
267
+
268
+
269
+ def account() -> str:
270
+ try:
271
+ return str(load_token().get("account") or "")
272
+ except GoogleError:
273
+ return ""
274
+
275
+
276
+ # ───────────────────────── consent flow ─────────────────────────
277
+
278
+ def auth_url(client_id: str, redirect_uri: str, state: str, challenge: str) -> str:
279
+ return AUTH_URI + "?" + urlencode({
280
+ "client_id": client_id,
281
+ "redirect_uri": redirect_uri,
282
+ "response_type": "code",
283
+ "scope": " ".join(requested_scopes()),
284
+ "access_type": "offline",
285
+ "prompt": "consent",
286
+ "state": state,
287
+ "code_challenge": challenge,
288
+ "code_challenge_method": "S256",
289
+ })
290
+
291
+
292
+ def connect(*, client_file: Optional[Path] = None, show: Callable[[str], None] = print,
293
+ open_browser: Optional[Callable[[str], object]] = None,
294
+ http: Optional[httpx.Client] = None, wait: float = CONSENT_WAIT,
295
+ on_listening: Optional[Callable[[str, str], None]] = None,
296
+ whoami: Optional[Callable[[str], str]] = None) -> dict:
297
+ """Run the consent flow in the person's own browser (loopback), verify the
298
+ scopes, store the token. Raises a named GoogleError; nothing is stored
299
+ on failure. ``on_listening(redirect_uri, state)`` is a test hook."""
300
+ from hyperclaw.brief.google_auth import _Catcher, _pkce
301
+ client = load_client(client_file)
302
+ verifier, challenge = _pkce()
303
+ state = secrets.token_urlsafe(24)
304
+ catcher = _Catcher()
305
+ redirect_uri = f"http://127.0.0.1:{catcher.port}"
306
+ catcher.start()
307
+ try:
308
+ url = auth_url(client["client_id"], redirect_uri, state, challenge)
309
+ show("Open this address in your own browser and sign in with your Google account. "
310
+ "HyperAgents asks to read Gmail, write drafts and send mail you approve, and to "
311
+ "read Calendar, Meet records and transcripts, Drive, Docs, Sheets and Slides:\n\n"
312
+ + url + "\n")
313
+ if open_browser:
314
+ try:
315
+ open_browser(url)
316
+ except Exception:
317
+ pass
318
+ if on_listening:
319
+ on_listening(redirect_uri, state)
320
+ if not catcher.event.wait(wait):
321
+ raise GoogleError(Reason.CONSENT, "timeout")
322
+ res = catcher.result
323
+ finally:
324
+ catcher.close()
325
+ if res.get("state") != state or res.get("error") or not res.get("code"):
326
+ raise GoogleError(Reason.CONSENT, "denied or state mismatch")
327
+ tok = token_request({
328
+ "grant_type": "authorization_code", "code": res["code"],
329
+ "redirect_uri": redirect_uri, "client_id": client["client_id"],
330
+ "client_secret": client["client_secret"], "code_verifier": verifier,
331
+ }, http)
332
+ scopes = check_scopes(tok.get("scope", ""))
333
+ if not tok.get("refresh_token"):
334
+ raise GoogleError(Reason.CONSENT, "no refresh token")
335
+ acct = ""
336
+ if whoami is not None and GMAIL_READ in scopes:
337
+ try:
338
+ acct = whoami(tok["access_token"])
339
+ except Exception:
340
+ acct = ""
341
+ where = save_token({
342
+ "refresh_token": tok["refresh_token"], "access_token": tok.get("access_token", ""),
343
+ "expiry": time.time() + float(tok.get("expires_in") or 0), "scopes": scopes,
344
+ "account": acct, "install": install_key(),
345
+ "connected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
346
+ })
347
+ missing = [s for s in SCOPES if s not in scopes]
348
+ return {"scopes": scopes, "missing": missing, "store": where, "account": acct}
349
+
350
+
351
+ def token_request(form: dict, http: Optional[httpx.Client] = None) -> dict:
352
+ c = http or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
353
+ try:
354
+ resp = c.post(token_uri(), data=form)
355
+ except httpx.HTTPError as e:
356
+ raise GoogleError(Reason.UNREACHABLE, type(e).__name__)
357
+ finally:
358
+ if http is None:
359
+ c.close()
360
+ try:
361
+ body = resp.json()
362
+ except Exception:
363
+ body = {}
364
+ if resp.status_code == 200 and isinstance(body, dict) and body.get("access_token"):
365
+ return body
366
+ err = body.get("error") if isinstance(body, dict) else ""
367
+ if err in ("invalid_grant", "unauthorized_client", "invalid_client"):
368
+ raise GoogleError(Reason.EXPIRED, str(err))
369
+ if resp.status_code >= 500:
370
+ raise GoogleError(Reason.UNREACHABLE, f"HTTP {resp.status_code}")
371
+ raise GoogleError(Reason.CONSENT, f"HTTP {resp.status_code}")
372
+
373
+
374
+ def access_token(http: Optional[httpx.Client] = None, now: Optional[float] = None,
375
+ force_refresh: bool = False) -> str:
376
+ """A valid access token for THIS install, refreshed when needed (and
377
+ reloaded from the store each time, so a re-sign-in takes effect at once).
378
+ A revoked grant raises EXPIRED: sign in again."""
379
+ tok = load_token()
380
+ if not tok.get("refresh_token"):
381
+ raise GoogleError(Reason.NOT_CONNECTED)
382
+ if tok.get("install") and tok["install"] != install_key():
383
+ raise GoogleError(Reason.NOT_CONNECTED, "token belongs to another install")
384
+ now = time.time() if now is None else now
385
+ if not force_refresh and tok.get("access_token") and float(tok.get("expiry") or 0) - 60 > now:
386
+ check_scopes(" ".join(tok.get("scopes") or []))
387
+ return tok["access_token"]
388
+ client = load_client()
389
+ fresh = token_request({
390
+ "grant_type": "refresh_token", "refresh_token": tok["refresh_token"],
391
+ "client_id": client["client_id"], "client_secret": client["client_secret"],
392
+ }, http)
393
+ scopes = check_scopes(fresh.get("scope") or " ".join(tok.get("scopes") or []))
394
+ tok.update({"access_token": fresh["access_token"], "scopes": scopes,
395
+ "expiry": now + float(fresh.get("expires_in") or 3600)})
396
+ save_token(tok)
397
+ return tok["access_token"]
@@ -0,0 +1,157 @@
1
+ """Google Calendar: list events and free/busy (reads, GET only); create or
2
+ change an event (ask tier, and only when Hyper's managed config grants
3
+ Calendar write; the default sign-in is read-only).
4
+
5
+ Free/busy is worked out from the events list (GET), so a read never sends a
6
+ request body anywhere. Restricted-client guard: each event (title,
7
+ description, location, attendees, organizer) is checked on its own.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ from typing import Any, Dict, List, Optional
13
+ from urllib.parse import quote
14
+
15
+ from hyperclaw import client_guard
16
+
17
+ from . import auth
18
+ from .api import GoogleApi
19
+ from .errors import GoogleError, Reason
20
+
21
+ MAX_EVENTS = 100
22
+ MAX_DESC = 2000
23
+ SOURCE = "google_calendar"
24
+
25
+
26
+ def _iso(value: str, *, field: str) -> str:
27
+ """RFC 3339 from 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM' or an RFC 3339 string (UTC if no zone)."""
28
+ v = (value or "").strip()
29
+ if not v:
30
+ raise ValueError(f"{field} is required")
31
+ for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
32
+ try:
33
+ d = _dt.datetime.strptime(v, fmt).replace(tzinfo=_dt.timezone.utc)
34
+ return d.isoformat().replace("+00:00", "Z")
35
+ except ValueError:
36
+ pass
37
+ try:
38
+ d = _dt.datetime.fromisoformat(v.replace("Z", "+00:00"))
39
+ except ValueError:
40
+ raise ValueError(f"{field} must be a date and time like 2026-10-14 15:00")
41
+ if d.tzinfo is None:
42
+ d = d.replace(tzinfo=_dt.timezone.utc)
43
+ return d.isoformat()
44
+
45
+
46
+ def _window(start: str, end: str, days: int) -> tuple:
47
+ now = _dt.datetime.now(_dt.timezone.utc)
48
+ t0 = _iso(start, field="start") if start else now.isoformat()
49
+ if end:
50
+ t1 = _iso(end, field="end")
51
+ else:
52
+ base = _dt.datetime.fromisoformat(t0.replace("Z", "+00:00"))
53
+ t1 = (base + _dt.timedelta(days=max(1, min(int(days or 7), 62)))).isoformat()
54
+ return t0, t1
55
+
56
+
57
+ def _event(e: dict) -> dict:
58
+ def when(x):
59
+ x = x or {}
60
+ return str(x.get("dateTime") or x.get("date") or "")
61
+ desc = str(e.get("description") or "")
62
+ return {
63
+ "id": str(e.get("id") or ""), "summary": str(e.get("summary") or ""),
64
+ "start": when(e.get("start")), "end": when(e.get("end")),
65
+ "location": str(e.get("location") or ""),
66
+ "description": desc[:MAX_DESC] + ("... [clipped]" if len(desc) > MAX_DESC else ""),
67
+ "attendees": [str(a.get("email") or a.get("displayName") or "")
68
+ for a in (e.get("attendees") or []) if isinstance(a, dict)],
69
+ "organizer": str((e.get("organizer") or {}).get("email") or ""),
70
+ "status": str(e.get("status") or ""),
71
+ "transparency": str(e.get("transparency") or "opaque"),
72
+ "meet_link": str(e.get("hangoutLink") or ""),
73
+ }
74
+
75
+
76
+ def _events(api: GoogleApi, calendar_id: str, t0: str, t1: str, query: str = "",
77
+ limit: int = MAX_EVENTS) -> List[dict]:
78
+ params = {"timeMin": t0, "timeMax": t1, "singleEvents": "true", "orderBy": "startTime",
79
+ "maxResults": max(1, min(int(limit or MAX_EVENTS), MAX_EVENTS))}
80
+ if query:
81
+ params["q"] = query
82
+ body = api.get("calendar", f"calendars/{quote(calendar_id or 'primary', safe='@.')}/events",
83
+ params)
84
+ return [_event(e) for e in (body.get("items") or []) if isinstance(e, dict)]
85
+
86
+
87
+ def list_events(api: GoogleApi, *, start: str = "", end: str = "", days: int = 7,
88
+ query: str = "", calendar_id: str = "primary", max_results: int = 25,
89
+ outside: Optional[bool] = None) -> dict:
90
+ t0, t1 = _window(start, end, days)
91
+ items = _events(api, calendar_id, t0, t1, query, max_results)
92
+ kept, withheld = client_guard.filter_items(items, SOURCE, outside=outside)
93
+ return {"calendar": calendar_id, "from": t0, "to": t1, "items": kept,
94
+ "withheld": withheld}
95
+
96
+
97
+ def free_busy(api: GoogleApi, *, start: str = "", end: str = "", days: int = 1,
98
+ calendar_id: str = "primary", outside: Optional[bool] = None) -> dict:
99
+ """Busy blocks only (no titles): times of events that block time."""
100
+ t0, t1 = _window(start, end, days)
101
+ busy = []
102
+ for e in _events(api, calendar_id, t0, t1):
103
+ if e["status"] == "cancelled" or e["transparency"] == "transparent":
104
+ continue
105
+ busy.append({"start": e["start"], "end": e["end"]})
106
+ return {"calendar": calendar_id, "from": t0, "to": t1, "busy": busy}
107
+
108
+
109
+ # ───────────────────────── writes (ask tier only) ─────────────────────────
110
+
111
+ def _need_write() -> None:
112
+ if not auth.calendar_write_allowed() or not auth.has_scope(auth.CALENDAR_WRITE):
113
+ raise GoogleError(Reason.MISSING_SCOPE, "calendar write")
114
+
115
+
116
+ def _event_body(title: str, start: str, end: str, location: str = "", description: str = "",
117
+ attendees: Optional[List[str]] = None) -> Dict[str, Any]:
118
+ body: Dict[str, Any] = {}
119
+ if title:
120
+ body["summary"] = title
121
+ if start:
122
+ body["start"] = {"dateTime": _iso(start, field="start")}
123
+ if end:
124
+ body["end"] = {"dateTime": _iso(end, field="end")}
125
+ if location:
126
+ body["location"] = location
127
+ if description:
128
+ body["description"] = description
129
+ if attendees:
130
+ body["attendees"] = [{"email": a} for a in attendees]
131
+ return body
132
+
133
+
134
+ def create_event(api: GoogleApi, *, title: str, start: str, end: str, location: str = "",
135
+ description: str = "", attendees: Optional[List[str]] = None,
136
+ calendar_id: str = "primary") -> dict:
137
+ _need_write()
138
+ body = _event_body(title, start, end, location, description, attendees)
139
+ res = api.post("calendar", f"calendars/{quote(calendar_id, safe='@.')}/events"
140
+ f"?sendUpdates=none", body)
141
+ if not res.get("id"):
142
+ raise GoogleError(Reason.UNREADABLE, "no event id")
143
+ return {"event_id": str(res["id"])}
144
+
145
+
146
+ def update_event(api: GoogleApi, *, event_id: str, title: str = "", start: str = "",
147
+ end: str = "", location: str = "", description: str = "",
148
+ calendar_id: str = "primary") -> dict:
149
+ _need_write()
150
+ body = _event_body(title, start, end, location, description)
151
+ if not body:
152
+ raise ValueError("nothing to change")
153
+ res = api.patch("calendar", f"calendars/{quote(calendar_id, safe='@.')}/events/"
154
+ f"{quote(event_id, safe='')}?sendUpdates=none", body)
155
+ if not res.get("id"):
156
+ raise GoogleError(Reason.UNREADABLE, "no event id")
157
+ return {"event_id": str(res["id"])}
@@ -0,0 +1,72 @@
1
+ """`hyperclaw google ...` (local terminal = the owner): connect, status, disconnect.
2
+
3
+ One sign-in per person, in their own browser. The token is never shown.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import typer
8
+
9
+ google_app = typer.Typer(help="Google Workspace for HyperAgents: Gmail, Calendar, Drive, Docs, "
10
+ "Sheets, Slides and Meet (sends and changes need your approval)")
11
+
12
+
13
+ def _env() -> None:
14
+ try:
15
+ from hyperclaw.userconfig import load_env_into_process
16
+ load_env_into_process()
17
+ except Exception:
18
+ pass
19
+
20
+
21
+ def _whoami(token: str) -> str:
22
+ from .api import GoogleApi
23
+ from .gmail import profile_email
24
+ with GoogleApi(token_fn=lambda **_: token) as api:
25
+ return profile_email(api)
26
+
27
+
28
+ @google_app.command("connect")
29
+ def connect_cmd(open_browser: bool = typer.Option(
30
+ False, "--open", help="Open the sign-in page in your default browser")) -> None:
31
+ """Sign in to Google in your own browser (loopback; nothing is shared with other installs)."""
32
+ _env()
33
+ from . import auth
34
+ from .errors import GoogleError
35
+ opener = None
36
+ if open_browser:
37
+ import webbrowser
38
+ opener = webbrowser.open
39
+ try:
40
+ res = auth.connect(show=typer.echo, open_browser=opener, whoami=_whoami)
41
+ except GoogleError as e:
42
+ typer.echo(e.text)
43
+ raise typer.Exit(1)
44
+ who = f" as {res['account']}" if res.get("account") else ""
45
+ typer.echo(f"✓ Google connected{who}. Token kept in "
46
+ f"{'the system keychain' if res['store'] == 'os-secret-store' else 'a private file'}"
47
+ f" for this install only.")
48
+ if res.get("missing"):
49
+ typer.echo("Not granted at sign-in (the tools that need these will say so): "
50
+ + ", ".join(s.rsplit("/", 1)[-1] for s in res["missing"]))
51
+
52
+
53
+ @google_app.command("status")
54
+ def status_cmd() -> None:
55
+ """Is Google connected on this install, and with which access."""
56
+ _env()
57
+ from . import auth
58
+ if not auth.connected():
59
+ typer.echo("Google: not connected. Run `hyperclaw google connect`.")
60
+ return
61
+ acct = auth.account()
62
+ typer.echo(f"Google: connected{(' as ' + acct) if acct else ''} "
63
+ f"({'system keychain' if auth.store_name() == 'os-secret-store' else 'private file'}).")
64
+ typer.echo("Access: " + ", ".join(s.rsplit("/", 1)[-1] for s in auth.granted_scopes()))
65
+
66
+
67
+ @google_app.command("disconnect")
68
+ def disconnect_cmd() -> None:
69
+ """Remove this install's Google token."""
70
+ _env()
71
+ from . import auth
72
+ typer.echo("Google disconnected." if auth.disconnect() else "Google wasn't connected.")