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,269 @@
1
+ """Read-only Google Docs and Sheets readers (GET only).
2
+
3
+ Docs API v1 gives the title, the text and the links in a Doc (no Drive scope
4
+ needed); Sheets API v4 gives a spreadsheet's title, tab names and values.
5
+ Per-file failures are named (no access, deleted, couldn't be returned);
6
+ whole-run failures (expired access, Google unreachable) raise a BriefError.
7
+ No URL, file id or response body is ever logged: the logger gets reason
8
+ codes only, and the brief's link filter is installed on the HTTP loggers.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import Callable, List, Optional, Tuple
17
+ from urllib.parse import quote, urlsplit
18
+
19
+ import httpx
20
+
21
+ from hyperclaw.brief import links
22
+ from hyperclaw.brief.errors import BriefError, Reason
23
+ from hyperclaw.taint import source as taint_source
24
+
25
+ log = logging.getLogger("hyperclaw.brief")
26
+
27
+ DOCS_API = "https://docs.googleapis.com"
28
+ SHEETS_API = "https://sheets.googleapis.com"
29
+ TIMEOUT = 20.0
30
+ MAX_RETRIES = 3
31
+ MAX_WAIT = 30.0
32
+ MAX_TEXT = 20_000 # characters kept per file
33
+ MAX_ROWS = 300 # rows kept per tab
34
+ MAX_COLS = 15
35
+ MAX_TABS = 10
36
+
37
+
38
+ def _api(env: str, default: str) -> str:
39
+ """API base. A loopback override exists for tests only."""
40
+ override = os.environ.get(env, "").strip()
41
+ if override and (urlsplit(override).hostname or "") in ("127.0.0.1", "localhost", "::1"):
42
+ return override.rstrip("/")
43
+ return default
44
+
45
+
46
+ @dataclass
47
+ class Link:
48
+ url: str
49
+ anchor: str
50
+
51
+
52
+ @dataclass
53
+ class DocContent:
54
+ title: str
55
+ text: str
56
+ links: List[Link] = field(default_factory=list)
57
+
58
+
59
+ @dataclass
60
+ class SheetContent:
61
+ title: str
62
+ tabs: List[Tuple[str, list]] = field(default_factory=list) # (tab title, rows)
63
+
64
+ def text(self) -> str:
65
+ out = []
66
+ for tab, rows in self.tabs:
67
+ out.append(f"[tab: {tab}]")
68
+ for r in rows:
69
+ out.append(" | ".join(str(c) for c in r))
70
+ return "\n".join(out)[:MAX_TEXT]
71
+
72
+
73
+ def new_client() -> httpx.Client:
74
+ return httpx.Client(timeout=TIMEOUT, follow_redirects=False)
75
+
76
+
77
+ class FileError(Exception):
78
+ """A per-file failure with a skip reason (no access, deleted, ...)."""
79
+
80
+ def __init__(self, reason: Reason):
81
+ self.reason = reason
82
+ super().__init__(reason.value)
83
+
84
+
85
+ class GoogleReader:
86
+ def __init__(self, token: str, *, client: Optional[httpx.Client] = None,
87
+ sleep: Callable[[float], None] = time.sleep):
88
+ links.install_log_filter()
89
+ self._headers = {"Authorization": f"Bearer {token}"}
90
+ self.client = client or new_client()
91
+ self.sleep = sleep
92
+ self.docs_api = _api("HYPERCLAW_BRIEF_DOCS_API", DOCS_API)
93
+ self.sheets_api = _api("HYPERCLAW_BRIEF_SHEETS_API", SHEETS_API)
94
+
95
+ def close(self) -> None:
96
+ try:
97
+ self.client.close()
98
+ except Exception:
99
+ pass
100
+
101
+ # ── HTTP ──
102
+ def _get(self, url: str, params: Optional[list] = None) -> dict:
103
+ for attempt in range(MAX_RETRIES + 1):
104
+ try:
105
+ resp = self.client.get(url, params=params or [], headers=self._headers)
106
+ except httpx.HTTPError as e:
107
+ if attempt >= MAX_RETRIES:
108
+ raise BriefError(Reason.GOOGLE_UNREACHABLE, type(e).__name__)
109
+ self.sleep(min(2 ** attempt, MAX_WAIT))
110
+ continue
111
+ code = resp.status_code
112
+ if code == 429:
113
+ if attempt >= MAX_RETRIES:
114
+ raise BriefError(Reason.GOOGLE_RATE_LIMITED, "429")
115
+ self.sleep(_retry_after(resp))
116
+ continue
117
+ if code >= 500:
118
+ if attempt >= MAX_RETRIES:
119
+ raise BriefError(Reason.GOOGLE_UNREACHABLE, f"HTTP {code}")
120
+ self.sleep(min(2 ** attempt, MAX_WAIT))
121
+ continue
122
+ if code == 401:
123
+ raise BriefError(Reason.GOOGLE_EXPIRED, "401")
124
+ if code == 403:
125
+ if _status(resp) == "PERMISSION_DENIED" and "scope" in _message(resp).lower():
126
+ raise BriefError(Reason.GOOGLE_MISSING_SCOPE, "403 scope")
127
+ raise FileError(Reason.NO_ACCESS)
128
+ if code in (404, 410):
129
+ raise FileError(Reason.DELETED)
130
+ if code == 400:
131
+ # a Drive file that isn't a native Doc/Sheet (an uploaded .docx/.xlsx)
132
+ raise FileError(Reason.NOT_DOC_OR_SHEET)
133
+ if code != 200:
134
+ raise FileError(Reason.UNREADABLE)
135
+ try:
136
+ body = resp.json()
137
+ except Exception:
138
+ raise FileError(Reason.UNREADABLE)
139
+ if not isinstance(body, dict):
140
+ raise FileError(Reason.UNREADABLE)
141
+ return body
142
+ raise BriefError(Reason.GOOGLE_UNREACHABLE, "retries exhausted") # pragma: no cover
143
+
144
+ # ── Docs ──
145
+ @taint_source("google_docs", "google")
146
+ def doc(self, file_id: str) -> DocContent:
147
+ body = self._get(f"{self.docs_api}/v1/documents/{quote(file_id, safe='')}",
148
+ [("includeTabsContent", "true")])
149
+ title = str(body.get("title") or "").strip()
150
+ texts, found = [], []
151
+ tabs = body.get("tabs")
152
+ if isinstance(tabs, list) and tabs:
153
+ for tab in _walk_tabs(tabs):
154
+ dt = tab.get("documentTab") or {}
155
+ _content(((dt.get("body") or {}).get("content")) or [], texts, found)
156
+ else:
157
+ _content(((body.get("body") or {}).get("content")) or [], texts, found)
158
+ text = "".join(texts)
159
+ return DocContent(title=title, text=text[:MAX_TEXT], links=_merge_links(found))
160
+
161
+ # ── Sheets ──
162
+ @taint_source("google_sheets", "google")
163
+ def sheet(self, file_id: str) -> SheetContent:
164
+ base = f"{self.sheets_api}/v4/spreadsheets/{quote(file_id, safe='')}"
165
+ meta = self._get(base, [("fields", "properties.title,sheets.properties.title")])
166
+ title = str((meta.get("properties") or {}).get("title") or "").strip()
167
+ tab_names = [str((s.get("properties") or {}).get("title") or "")
168
+ for s in (meta.get("sheets") or []) if isinstance(s, dict)][:MAX_TABS]
169
+ tabs = []
170
+ if tab_names:
171
+ ranges = [("ranges", f"'{t.replace(chr(39), chr(39) * 2)}'!A1:O{MAX_ROWS}")
172
+ for t in tab_names]
173
+ vals = self._get(f"{base}/values:batchGet",
174
+ ranges + [("majorDimension", "ROWS"),
175
+ ("valueRenderOption", "FORMATTED_VALUE")])
176
+ for name, vr in zip(tab_names, vals.get("valueRanges") or []):
177
+ rows = [[str(c) for c in (r or [])][:MAX_COLS]
178
+ for r in (vr.get("values") or [])][:MAX_ROWS]
179
+ tabs.append((name, rows))
180
+ return SheetContent(title=title, tabs=tabs)
181
+
182
+
183
+ def _walk_tabs(tabs: list):
184
+ for t in tabs:
185
+ if not isinstance(t, dict):
186
+ continue
187
+ yield t
188
+ yield from _walk_tabs(t.get("childTabs") or [])
189
+
190
+
191
+ def _content(elements: list, texts: list, found: list) -> None:
192
+ for el in elements:
193
+ if not isinstance(el, dict):
194
+ continue
195
+ para = el.get("paragraph")
196
+ if para:
197
+ for pe in para.get("elements") or []:
198
+ tr = pe.get("textRun")
199
+ if tr:
200
+ content = tr.get("content") or ""
201
+ texts.append(content)
202
+ url = ((tr.get("textStyle") or {}).get("link") or {}).get("url")
203
+ if url:
204
+ found.append((url, content))
205
+ rl = pe.get("richLink")
206
+ if rl:
207
+ props = rl.get("richLinkProperties") or {}
208
+ t = props.get("title") or ""
209
+ texts.append(t)
210
+ if props.get("uri"):
211
+ found.append((props["uri"], t))
212
+ continue
213
+ table = el.get("table")
214
+ if table:
215
+ for row in table.get("tableRows") or []:
216
+ cells = []
217
+ for cell in row.get("tableCells") or []:
218
+ sub: list = []
219
+ _content(cell.get("content") or [], sub, found)
220
+ cells.append(" ".join("".join(sub).split()))
221
+ texts.append(" | ".join(cells) + "\n")
222
+ continue
223
+ toc = el.get("tableOfContents")
224
+ if toc:
225
+ _content(toc.get("content") or [], [], [])
226
+
227
+
228
+ def _merge_links(found: list) -> List[Link]:
229
+ """Consecutive runs of one link become one Link with the joined text;
230
+ the same link twice is kept once (first anchor wins)."""
231
+ out: List[Link] = []
232
+ seen = set()
233
+ prev_url = None
234
+ for url, text in found:
235
+ if out and url == prev_url:
236
+ out[-1].anchor += text
237
+ continue
238
+ prev_url = url
239
+ if url in seen:
240
+ continue
241
+ seen.add(url)
242
+ out.append(Link(url=url, anchor=text))
243
+ for lk in out:
244
+ lk.anchor = " ".join(lk.anchor.split())
245
+ return out
246
+
247
+
248
+ def _retry_after(resp) -> float:
249
+ try:
250
+ return max(0.0, min(float(resp.headers.get("Retry-After", "1")), MAX_WAIT))
251
+ except ValueError:
252
+ return 1.0
253
+
254
+
255
+ def _err(resp) -> dict:
256
+ try:
257
+ b = resp.json()
258
+ e = b.get("error") if isinstance(b, dict) else None
259
+ return e if isinstance(e, dict) else {}
260
+ except Exception:
261
+ return {}
262
+
263
+
264
+ def _status(resp) -> str:
265
+ return str(_err(resp).get("status") or "")
266
+
267
+
268
+ def _message(resp) -> str:
269
+ return str(_err(resp).get("message") or "")
@@ -0,0 +1,294 @@
1
+ """Google sign-in for the brief: installed-app (Desktop) OAuth, loopback redirect.
2
+
3
+ - The OAuth client (client id/secret) is a file the owner keeps outside the
4
+ repo; config.json ``brief.google_client_file`` holds only its path.
5
+ - Only two read-only scopes are requested, nothing else (no Gmail, no Drive):
6
+ documents.readonly and spreadsheets.readonly. A grant that carries any
7
+ other scope is refused and nothing is stored; so is a grant missing one.
8
+ - PKCE (S256) + a random state; the redirect goes to 127.0.0.1 on a random
9
+ port. The browser is opened only when asked (``open_browser``); otherwise
10
+ the address is shown for the owner to open.
11
+ - The token is stored in $HYPERCLAW_ROOT/brief/google-token.json, 0600.
12
+ It is never printed, logged or sent to the model.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import secrets
22
+ import threading
23
+ import time
24
+ from http.server import BaseHTTPRequestHandler, HTTPServer
25
+ from pathlib import Path
26
+ from typing import Callable, Optional
27
+ from urllib.parse import parse_qs, urlencode, urlsplit
28
+
29
+ import httpx
30
+
31
+ from hyperclaw.brief import store
32
+ from hyperclaw.brief.errors import BriefError, Reason
33
+
34
+ log = logging.getLogger("hyperclaw.brief")
35
+
36
+ SCOPES = ("https://www.googleapis.com/auth/documents.readonly",
37
+ "https://www.googleapis.com/auth/spreadsheets.readonly")
38
+ AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
39
+ TOKEN_URI = "https://oauth2.googleapis.com/token"
40
+ TIMEOUT = 20.0
41
+ CONSENT_WAIT = 300.0
42
+ DONE_PAGE = (b"<!doctype html><meta charset=utf-8><title>HyperAgents</title>"
43
+ b"<p>HyperAgents has your answer from Google. You can close this tab and go "
44
+ b"back to the terminal.</p>")
45
+
46
+
47
+ def token_uri() -> str:
48
+ """Google's token endpoint. A loopback override exists for tests only."""
49
+ override = os.environ.get("HYPERCLAW_BRIEF_GOOGLE_TOKEN_URI", "").strip()
50
+ if override:
51
+ host = urlsplit(override).hostname or ""
52
+ if host in ("127.0.0.1", "localhost", "::1"):
53
+ return override
54
+ return TOKEN_URI
55
+
56
+
57
+ # ── client file ───────────────────────────────────────────────────────────
58
+
59
+ def client_file() -> str:
60
+ return str(store.config().get("google_client_file") or "")
61
+
62
+
63
+ def load_client(path: Optional[str] = None) -> dict:
64
+ """{"client_id", "client_secret"} from a Desktop-app client JSON file.
65
+ With no file configured, a team install uses the team Google client from
66
+ its Hyper-signed team profile (hyperclaw.teamsetup.profile)."""
67
+ if not (path or client_file()):
68
+ try:
69
+ from hyperclaw.teamsetup.profile import google_client
70
+ team = google_client()
71
+ except Exception:
72
+ team = {}
73
+ if team:
74
+ return team
75
+ p = Path(os.path.expanduser(path or client_file() or ""))
76
+ if not str(p) or not p.is_file():
77
+ raise BriefError(Reason.GOOGLE_CLIENT_FILE, "missing")
78
+ try:
79
+ data = json.loads(p.read_text(encoding="utf-8"))
80
+ except Exception:
81
+ raise BriefError(Reason.GOOGLE_CLIENT_FILE, "unparseable")
82
+ inst = data.get("installed") if isinstance(data, dict) else None
83
+ if not isinstance(inst, dict) or not inst.get("client_id") or not inst.get("client_secret"):
84
+ raise BriefError(Reason.GOOGLE_CLIENT_FILE, "not a desktop client")
85
+ return {"client_id": str(inst["client_id"]), "client_secret": str(inst["client_secret"])}
86
+
87
+
88
+ # ── scopes ────────────────────────────────────────────────────────────────
89
+
90
+ def check_scopes(granted: str) -> list:
91
+ got = sorted(set((granted or "").split()))
92
+ extra = [s for s in got if s not in SCOPES]
93
+ if extra:
94
+ raise BriefError(Reason.GOOGLE_EXTRA_SCOPE, f"{len(extra)} extra")
95
+ if set(SCOPES) - set(got):
96
+ raise BriefError(Reason.GOOGLE_MISSING_SCOPE)
97
+ return got
98
+
99
+
100
+ # ── token storage ─────────────────────────────────────────────────────────
101
+
102
+ def load_token() -> dict:
103
+ p = store.token_path()
104
+ if not p.exists():
105
+ return {}
106
+ if store.too_open(p):
107
+ raise BriefError(Reason.TOKEN_FILE_OPEN)
108
+ return store.read_json(p)
109
+
110
+
111
+ def save_token(tok: dict) -> None:
112
+ store.write_json(store.token_path(), tok)
113
+
114
+
115
+ def connected() -> bool:
116
+ try:
117
+ return bool(load_token().get("refresh_token"))
118
+ except BriefError:
119
+ return False
120
+
121
+
122
+ def disconnect() -> bool:
123
+ try:
124
+ store.token_path().unlink()
125
+ return True
126
+ except FileNotFoundError:
127
+ return False
128
+
129
+
130
+ # ── the consent flow ──────────────────────────────────────────────────────
131
+
132
+ def _pkce() -> tuple:
133
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode()
134
+ challenge = base64.urlsafe_b64encode(
135
+ hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
136
+ return verifier, challenge
137
+
138
+
139
+ class _Catcher:
140
+ """One-shot loopback HTTP server that waits for Google's redirect."""
141
+
142
+ def __init__(self):
143
+ self.result: dict = {}
144
+ self.event = threading.Event()
145
+ outer = self
146
+
147
+ class H(BaseHTTPRequestHandler):
148
+ def log_message(self, *a): # never log the query (it holds the code)
149
+ pass
150
+
151
+ def do_GET(self):
152
+ q = {k: v[0] for k, v in parse_qs(urlsplit(self.path).query).items()}
153
+ if "code" in q or "error" in q:
154
+ outer.result = q
155
+ outer.event.set()
156
+ self.send_response(200)
157
+ self.send_header("Content-Type", "text/html; charset=utf-8")
158
+ self.send_header("Content-Length", str(len(DONE_PAGE)))
159
+ self.end_headers()
160
+ self.wfile.write(DONE_PAGE)
161
+
162
+ self.httpd = HTTPServer(("127.0.0.1", 0), H)
163
+ self.port = self.httpd.server_address[1]
164
+ self.thread = threading.Thread(target=self._serve, daemon=True)
165
+
166
+ def _serve(self):
167
+ while not self.event.is_set():
168
+ self.httpd.handle_request()
169
+
170
+ def start(self):
171
+ self.httpd.timeout = 0.5
172
+ self.thread.start()
173
+
174
+ def close(self):
175
+ self.event.set()
176
+ try:
177
+ self.httpd.server_close()
178
+ except Exception:
179
+ pass
180
+
181
+
182
+ def auth_url(client_id: str, redirect_uri: str, state: str, challenge: str) -> str:
183
+ return AUTH_URI + "?" + urlencode({
184
+ "client_id": client_id,
185
+ "redirect_uri": redirect_uri,
186
+ "response_type": "code",
187
+ "scope": " ".join(SCOPES),
188
+ "access_type": "offline",
189
+ "prompt": "consent",
190
+ "state": state,
191
+ "code_challenge": challenge,
192
+ "code_challenge_method": "S256",
193
+ })
194
+
195
+
196
+ def connect(*, client_path: Optional[str] = None, show: Callable[[str], None] = print,
197
+ open_browser: Optional[Callable[[str], object]] = None,
198
+ http: Optional[httpx.Client] = None, wait: float = CONSENT_WAIT,
199
+ on_listening: Optional[Callable[[str, str], None]] = None,
200
+ client: Optional[dict] = None) -> dict:
201
+ """Run the consent flow, verify the scopes, store the token.
202
+
203
+ ``on_listening(redirect_uri, state)`` is a test hook. Returns
204
+ {"scopes": [...]}; raises a named BriefError and stores nothing on failure."""
205
+ client = client or load_client(client_path)
206
+ verifier, challenge = _pkce()
207
+ state = secrets.token_urlsafe(24)
208
+ catcher = _Catcher()
209
+ redirect_uri = f"http://127.0.0.1:{catcher.port}"
210
+ catcher.start()
211
+ try:
212
+ url = auth_url(client["client_id"], redirect_uri, state, challenge)
213
+ show("Open this address in your browser and sign in with the Google account that can "
214
+ "see your Doc and Sheet. HyperAgents asks only to view Docs and Sheets "
215
+ "(read-only):\n\n" + url + "\n")
216
+ if open_browser:
217
+ try:
218
+ open_browser(url)
219
+ except Exception:
220
+ pass
221
+ if on_listening:
222
+ on_listening(redirect_uri, state)
223
+ if not catcher.event.wait(wait):
224
+ raise BriefError(Reason.GOOGLE_CONSENT, "timeout")
225
+ res = catcher.result
226
+ finally:
227
+ catcher.close()
228
+ if res.get("state") != state or res.get("error") or not res.get("code"):
229
+ raise BriefError(Reason.GOOGLE_CONSENT, "denied or state mismatch")
230
+ tok = _token_request({
231
+ "grant_type": "authorization_code",
232
+ "code": res["code"],
233
+ "redirect_uri": redirect_uri,
234
+ "client_id": client["client_id"],
235
+ "client_secret": client["client_secret"],
236
+ "code_verifier": verifier,
237
+ }, http)
238
+ scopes = check_scopes(tok.get("scope", ""))
239
+ if not tok.get("refresh_token"):
240
+ raise BriefError(Reason.GOOGLE_CONSENT, "no refresh token")
241
+ save_token({
242
+ "refresh_token": tok["refresh_token"],
243
+ "access_token": tok.get("access_token", ""),
244
+ "expiry": time.time() + float(tok.get("expires_in") or 0),
245
+ "scopes": scopes,
246
+ "connected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
247
+ })
248
+ return {"scopes": scopes}
249
+
250
+
251
+ def _token_request(form: dict, http: Optional[httpx.Client] = None) -> dict:
252
+ c = http or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
253
+ try:
254
+ resp = c.post(token_uri(), data=form)
255
+ except httpx.HTTPError as e:
256
+ raise BriefError(Reason.GOOGLE_UNREACHABLE, type(e).__name__)
257
+ finally:
258
+ if http is None:
259
+ c.close()
260
+ try:
261
+ body = resp.json()
262
+ except Exception:
263
+ body = {}
264
+ if resp.status_code == 200 and isinstance(body, dict) and body.get("access_token"):
265
+ return body
266
+ err = body.get("error") if isinstance(body, dict) else ""
267
+ if err in ("invalid_grant", "unauthorized_client", "invalid_client"):
268
+ raise BriefError(Reason.GOOGLE_EXPIRED, str(err))
269
+ if resp.status_code >= 500:
270
+ raise BriefError(Reason.GOOGLE_UNREACHABLE, f"HTTP {resp.status_code}")
271
+ raise BriefError(Reason.GOOGLE_CONSENT, f"HTTP {resp.status_code}")
272
+
273
+
274
+ def access_token(http: Optional[httpx.Client] = None, now: Optional[float] = None) -> str:
275
+ """A valid access token, refreshed when needed. Scopes re-checked."""
276
+ tok = load_token()
277
+ if not tok.get("refresh_token"):
278
+ raise BriefError(Reason.GOOGLE_NOT_CONNECTED)
279
+ now = time.time() if now is None else now
280
+ if tok.get("access_token") and float(tok.get("expiry") or 0) - 60 > now:
281
+ check_scopes(" ".join(tok.get("scopes") or []))
282
+ return tok["access_token"]
283
+ client = load_client()
284
+ fresh = _token_request({
285
+ "grant_type": "refresh_token",
286
+ "refresh_token": tok["refresh_token"],
287
+ "client_id": client["client_id"],
288
+ "client_secret": client["client_secret"],
289
+ }, http)
290
+ scopes = check_scopes(fresh.get("scope") or " ".join(tok.get("scopes") or []))
291
+ tok.update({"access_token": fresh["access_token"], "scopes": scopes,
292
+ "expiry": now + float(fresh.get("expires_in") or 3600)})
293
+ save_token(tok)
294
+ return tok["access_token"]
@@ -0,0 +1,124 @@
1
+ """Links and file IDs never leave this machine's private config.
2
+
3
+ - ``parse`` turns a pasted Google link into (kind, file id). Only the id is
4
+ stored (owner-only sources.json), never the link.
5
+ - ``scrub`` removes every URL and anything shaped like a Google file id from
6
+ text before it reaches the model, the brief, a notice or a log line.
7
+ - ``link_label`` names a linked file by the text it was linked from in the
8
+ master Doc (read from the source); a bare pasted link gets a neutral
9
+ "untitled link" label instead, so the link itself is never shown.
10
+ - ``LinkLogFilter`` drops HTTP-client log records that carry a Google file
11
+ URL or a Telegram bot URL (httpx logs every request URL at INFO).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import logging
17
+ import re
18
+ from typing import Iterable, Optional, Tuple
19
+ from urllib.parse import parse_qs, urlsplit
20
+
21
+ DOC = "doc"
22
+ SHEET = "sheet"
23
+ OTHER = "other"
24
+
25
+ # Google file ids are long url-safe strings (typically 44 characters).
26
+ _ID = r"[A-Za-z0-9_-]{20,}"
27
+ ID_RE = re.compile(r"(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{25,}(?![A-Za-z0-9_-])")
28
+ URL_RE = re.compile(r"(?i)(?:https?://|www\.)[^\s<>\"'()\]]+")
29
+ _BARE_GOOGLE = re.compile(r"(?i)\b(?:docs|drive|sheets)\.google\.com/[^\s<>\"']*")
30
+ _PATH_ID = re.compile(rf"/d/(?:e/)?({_ID})")
31
+
32
+
33
+ def parse(url: str) -> Tuple[str, Optional[str]]:
34
+ """(kind, file id). kind is DOC, SHEET or OTHER; id None when it can't be
35
+ told (OTHER is returned for every non-Google-Docs/Sheets link)."""
36
+ raw = (url or "").strip()
37
+ if not raw:
38
+ return OTHER, None
39
+ if "://" not in raw:
40
+ raw = "https://" + raw
41
+ try:
42
+ parts = urlsplit(raw)
43
+ except ValueError:
44
+ return OTHER, None
45
+ host = (parts.hostname or "").lower()
46
+ path = parts.path or ""
47
+ if host == "docs.google.com":
48
+ m = _PATH_ID.search(path)
49
+ fid = m.group(1) if m else None
50
+ if path.startswith("/document/") and fid and "/d/e/" not in path:
51
+ return DOC, fid
52
+ if path.startswith("/spreadsheets/") and fid and "/d/e/" not in path:
53
+ return SHEET, fid
54
+ return OTHER, fid
55
+ if host == "drive.google.com":
56
+ m = _PATH_ID.search(path)
57
+ fid = m.group(1) if m else (parse_qs(parts.query).get("id") or [None])[0]
58
+ return OTHER, fid
59
+ return OTHER, None
60
+
61
+
62
+ def is_link(url: str) -> bool:
63
+ u = (url or "").strip().lower()
64
+ return u.startswith(("http://", "https://"))
65
+
66
+
67
+ def key_for(file_id: str) -> str:
68
+ """Stable, non-reversible key for a file (snapshot keys, dedupe)."""
69
+ return hashlib.sha256(("hyperclaw-brief:" + (file_id or "")).encode()).hexdigest()[:16]
70
+
71
+
72
+ def scrub(text: str, extra: Iterable[str] = ()) -> str:
73
+ """Remove URLs, Google file ids and any of ``extra`` (known ids) from text."""
74
+ if not text:
75
+ return ""
76
+ out = URL_RE.sub("[link]", str(text))
77
+ out = _BARE_GOOGLE.sub("[link]", out)
78
+ for x in extra:
79
+ if x:
80
+ out = out.replace(x, "[id]")
81
+ return ID_RE.sub("[id]", out)
82
+
83
+
84
+ def contains_link_or_id(text: str, extra: Iterable[str] = ()) -> bool:
85
+ t = text or ""
86
+ if URL_RE.search(t) or _BARE_GOOGLE.search(t) or ID_RE.search(t):
87
+ return True
88
+ return any(x and x in t for x in extra)
89
+
90
+
91
+ def link_label(anchor: str, n: int) -> str:
92
+ """The label shown for a linked file: its link text from the master Doc,
93
+ or "untitled link n" when the text is itself a link or carries an id."""
94
+ a = " ".join((anchor or "").split()).strip()
95
+ if not a or contains_link_or_id(a):
96
+ return f"untitled link {n}"
97
+ return a[:80]
98
+
99
+
100
+ class LinkLogFilter(logging.Filter):
101
+ """Drop log records whose message carries a Google file URL or a
102
+ Telegram bot URL. Installed on the HTTP client loggers by the brief."""
103
+
104
+ _PAT = re.compile(r"(?i)(docs\.googleapis\.com|sheets\.googleapis\.com|docs\.google\.com|"
105
+ r"drive\.google\.com|api\.telegram\.org/bot|oauth2\.googleapis\.com|"
106
+ r"/v1/documents/|/v4/spreadsheets/)")
107
+
108
+ def filter(self, record: logging.LogRecord) -> bool:
109
+ try:
110
+ msg = record.getMessage()
111
+ except Exception:
112
+ return True
113
+ return not self._PAT.search(msg)
114
+
115
+
116
+ _FILTER = LinkLogFilter()
117
+
118
+
119
+ def install_log_filter() -> None:
120
+ """Idempotent: attach the filter to the HTTP client loggers."""
121
+ for name in ("httpx", "httpcore", "urllib3", "urllib3.connectionpool"):
122
+ lg = logging.getLogger(name)
123
+ if _FILTER not in lg.filters:
124
+ lg.addFilter(_FILTER)