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,424 @@
1
+ """Run, deliver and schedule the daily brief (docs/DAILY-BRIEF.md).
2
+
3
+ Owner only: nothing here is reachable from a chat. Delivery goes to the
4
+ terminal (latest.txt, `hyperclaw brief now|last`) and to the owner's Telegram
5
+ chat(s) with link previews off. Failures are named with a fixed reason; a
6
+ brief is never made up.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from datetime import date, datetime, timedelta
15
+ from typing import Callable, List, Optional
16
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
17
+
18
+ import httpx
19
+
20
+ from hyperclaw.brief import collect as collect_mod
21
+ from hyperclaw.brief import compose, diff, google_auth, links, store
22
+ from hyperclaw.brief.errors import FIX, TEXT, BriefError, Reason
23
+ from hyperclaw.brief.google import GoogleReader
24
+ from hyperclaw.brief.tasks import read_tasks
25
+ from hyperclaw.chat_scope import OWNER, owner_ids
26
+
27
+ log = logging.getLogger("hyperclaw.brief")
28
+
29
+ TELEGRAM_API = "https://api.telegram.org"
30
+ TELEGRAM_LIMIT = 4096
31
+ LATE_GRACE = timedelta(minutes=10) # later than this after the slot = "late"
32
+ SLEEP_GAP = timedelta(minutes=3) # a tick gap longer than this = the Mac slept
33
+
34
+
35
+ # ── notices (failure-notice standard) ─────────────────────────────────────
36
+
37
+ def failure_notice(reason: Reason) -> str:
38
+ """'Sorry, I couldn't write today's brief. <why> <what to do>'. Names the
39
+ action, never implies success, never carries technical detail."""
40
+ detail = TEXT[reason][0].upper() + TEXT[reason][1:] + "."
41
+ fix = FIX.get(reason, "")
42
+ if fix:
43
+ detail += " " + fix
44
+ from hyperclaw.notices import failure_notice as standard
45
+ return standard(what="write today's brief", detail=detail, retry=False)
46
+
47
+
48
+ def activity(status: str, message: str) -> None:
49
+ """One row in the activity log (tools/ledger.py). Never a link or an id."""
50
+ try:
51
+ from hyperclaw.tools.ledger import get_ledger
52
+ get_ledger().record(conversation="daily_brief", surface="scheduler", tool="daily_brief",
53
+ effect="system", target="daily_brief", key=None, status=status,
54
+ message=links.scrub(message))
55
+ except Exception as e:
56
+ log.warning("brief: could not write the activity log (%s)", type(e).__name__)
57
+
58
+
59
+ # ── run ───────────────────────────────────────────────────────────────────
60
+
61
+ @dataclass
62
+ class Outcome:
63
+ ok: bool
64
+ text: str
65
+ reason: str = ""
66
+ sent: List[int] = field(default_factory=list)
67
+
68
+
69
+ def tz() -> ZoneInfo:
70
+ name = store.tz_name()
71
+ if name:
72
+ try:
73
+ return ZoneInfo(name)
74
+ except (ZoneInfoNotFoundError, ValueError):
75
+ log.warning("brief: unknown timezone in config; using the system zone")
76
+ local = datetime.now().astimezone().tzinfo
77
+ return local # type: ignore[return-value]
78
+
79
+
80
+ def run_brief(*, now: Optional[datetime] = None, scope: str = OWNER, opening: List[str] = (),
81
+ registry=None, client_factory: Optional[Callable] = None, http_client=None,
82
+ google_http=None, sleep=None) -> Outcome:
83
+ """Read, write with V2, save. Never raises a BriefError."""
84
+ links.install_log_filter()
85
+ if scope != OWNER:
86
+ return Outcome(False, failure_notice(Reason.NOT_OWNER), Reason.NOT_OWNER.value)
87
+ now = now or datetime.now(tz())
88
+
89
+ def fail(reason: Reason, extra: List[str] = ()) -> Outcome:
90
+ text = "\n".join([o for o in opening if o] + [failure_notice(reason)] + list(extra))
91
+ store.save_latest(text)
92
+ store.update_state(last_result={"ok": False, "reason": reason.value,
93
+ "at": now.isoformat(timespec="seconds")})
94
+ return Outcome(False, text, reason.value)
95
+
96
+ doc_id, sheet_id = store.source_id("doc"), store.source_id("sheet")
97
+ if not doc_id and not sheet_id:
98
+ return fail(Reason.NO_SOURCES)
99
+ try:
100
+ compose.v2_route(registry) # refuse before reading anything if V2 can't write it
101
+ token = google_auth.access_token(http=google_http)
102
+ except BriefError as e:
103
+ log.info("brief: not run (%s)", e.reason.value)
104
+ return fail(e.reason)
105
+
106
+ kw = {"client": http_client} if http_client is not None else {}
107
+ if sleep is not None:
108
+ kw["sleep"] = sleep
109
+ reader = GoogleReader(token, **kw)
110
+ try:
111
+ got = collect_mod.collect(reader, doc_id, sheet_id)
112
+ except BriefError as e:
113
+ log.info("brief: sources not read (%s)", e.reason.value)
114
+ return fail(e.reason)
115
+ finally:
116
+ if http_client is None:
117
+ reader.close()
118
+ try:
119
+ collect_mod.require_something(got)
120
+ except BriefError as e:
121
+ return fail(e.reason, [f"- {sk.label}: {TEXT[sk.reason]}." for sk in got.skipped
122
+ if sk.role != "linked"])
123
+ for kind, src in (("doc", got.master), ("sheet", got.todo)):
124
+ if src is not None and src.title:
125
+ store.set_title(kind, src.title)
126
+
127
+ today = now.date()
128
+ snap = store.load_snapshot()
129
+ d = diff.compare(got, snap)
130
+ tl = read_tasks(got.todo.sheet, today) if got.todo and got.todo.sheet else None
131
+ model_text = None
132
+ if got.master:
133
+ try:
134
+ model_text = compose.write(got, d, tl, today, registry=registry,
135
+ client_factory=client_factory)
136
+ except BriefError as e:
137
+ log.info("brief: not written (%s)", e.reason.value)
138
+ return fail(e.reason)
139
+ text = compose.render(got, d, tl, model_text, today, opening=opening)
140
+ diff.maybe_replace(got, now, snap)
141
+ store.save_latest(text)
142
+ store.update_state(last_result={"ok": True, "reason": "",
143
+ "at": now.isoformat(timespec="seconds")})
144
+ return Outcome(True, text)
145
+
146
+
147
+ DAY_HEADING = "Your day"
148
+
149
+
150
+ def run_brief_with_day(*, now: Optional[datetime] = None, opening: List[str] = (),
151
+ morning_sources=None, **kw) -> Outcome:
152
+ """The scheduled morning brief: the daily brief plus the "your day"
153
+ sections (calendar, email that needs you, Slack mentions and DMs, MRs
154
+ waiting on you) from whichever connectors are present (team.morning).
155
+ With no doc/sheet configured, the day sections are the brief."""
156
+ now = now or datetime.now(tz())
157
+ out = run_brief(now=now, opening=opening, **kw)
158
+ from hyperclaw.team import morning
159
+ if kw.get("scope", OWNER) != OWNER or not morning.enabled():
160
+ return out
161
+ try:
162
+ day = morning.render(now, morning_sources)
163
+ except Exception as e:
164
+ log.error("brief: day sections not built (%s)", type(e).__name__, exc_info=True)
165
+ return out
166
+ if not out.ok and out.reason == Reason.NO_SOURCES.value:
167
+ text = "\n".join([o for o in opening if o] + [DAY_HEADING, "", day])
168
+ store.save_latest(text)
169
+ store.update_state(last_result={"ok": True, "reason": "",
170
+ "at": now.isoformat(timespec="seconds")})
171
+ return Outcome(True, text)
172
+ text = out.text.rstrip() + "\n\n" + DAY_HEADING + "\n\n" + day
173
+ store.save_latest(text)
174
+ return Outcome(out.ok, text, out.reason, out.sent)
175
+
176
+
177
+ # ── delivery ──────────────────────────────────────────────────────────────
178
+
179
+ def _chunks(text: str, n: int = TELEGRAM_LIMIT) -> List[str]:
180
+ """Split at the last line break that fits, else hard at n."""
181
+ out = []
182
+ rest = text
183
+ while len(rest) > n:
184
+ cut = rest.rfind("\n", 0, n)
185
+ if cut <= 0:
186
+ cut = n
187
+ out.append(rest[:cut])
188
+ rest = rest[cut:].lstrip("\n")
189
+ if rest.strip():
190
+ out.append(rest)
191
+ return out
192
+
193
+
194
+ def new_http() -> httpx.Client:
195
+ return httpx.Client(timeout=20.0, follow_redirects=False)
196
+
197
+
198
+ def telegram_send(chat_id: int, text: str, *, client: Optional[httpx.Client] = None) -> bool:
199
+ """Bot API sendMessage with link previews OFF, plain text, confirmed by a
200
+ message_id receipt. The bot token is never logged."""
201
+ links.install_log_filter()
202
+ token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
203
+ if not token:
204
+ return False
205
+ base = os.environ.get("HYPERCLAW_BRIEF_TELEGRAM_API", "").strip()
206
+ if not base or (httpx.URL(base).host not in ("127.0.0.1", "localhost", "::1")):
207
+ base = TELEGRAM_API
208
+ c = client or new_http()
209
+ try:
210
+ from hyperclaw.tools.receipts import telegram_receipt
211
+ for part in _chunks(text):
212
+ resp = c.post(f"{base}/bot{token}/sendMessage", json={
213
+ "chat_id": chat_id, "text": part,
214
+ "link_preview_options": {"is_disabled": True},
215
+ "disable_web_page_preview": True,
216
+ })
217
+ mid, _why = telegram_receipt(resp)
218
+ if not mid:
219
+ log.error("brief: Telegram did not confirm delivery")
220
+ return False
221
+ return True
222
+ except httpx.HTTPError as e:
223
+ log.error("brief: Telegram delivery failed (%s)", type(e).__name__)
224
+ return False
225
+ finally:
226
+ if client is None:
227
+ c.close()
228
+
229
+
230
+ async def deliver(outcome: Outcome, send: Optional[Callable] = None) -> List[int]:
231
+ """Send to the owner's Telegram chat(s) only (never a paired or guest chat)."""
232
+ sent: List[int] = []
233
+ text = (outcome.text or "").strip()
234
+ if not text:
235
+ return sent
236
+ for chat_id in sorted(owner_ids("telegram")):
237
+ try:
238
+ if send is not None:
239
+ ok = await send(int(chat_id), text)
240
+ else:
241
+ ok = await asyncio.to_thread(telegram_send, int(chat_id), text)
242
+ if ok:
243
+ sent.append(int(chat_id))
244
+ except Exception as e:
245
+ log.error("brief: Telegram delivery failed (%s)", type(e).__name__)
246
+ outcome.sent = sent
247
+ return sent
248
+
249
+
250
+ # ── schedule: one brief a day, caught up once on wake ─────────────────────
251
+
252
+ @dataclass
253
+ class Due:
254
+ run: bool
255
+ late: bool = False
256
+ cause: str = "" # "asleep" | "not_running" | ""
257
+ missed: List[date] = field(default_factory=list)
258
+ slot: Optional[datetime] = None
259
+
260
+
261
+ def _fmt_hm(dt) -> str:
262
+ return f"{dt.hour}:{dt.minute:02d}"
263
+
264
+
265
+ def _fmt_day(d: date) -> str:
266
+ return d.strftime("%a %d %b").replace(" 0", " ")
267
+
268
+
269
+ def first_day(scheduled_at: Optional[datetime], hh: int, mm: int) -> Optional[date]:
270
+ """The first day whose slot came after the schedule was set."""
271
+ if scheduled_at is None:
272
+ return None
273
+ slot = scheduled_at.replace(hour=hh, minute=mm, second=0, microsecond=0)
274
+ return scheduled_at.date() if scheduled_at <= slot else scheduled_at.date() + timedelta(days=1)
275
+
276
+
277
+ def decide(now: datetime, hh: int, mm: int, *, last_run: Optional[date],
278
+ prev_tick: Optional[datetime], scheduled_at: Optional[datetime] = None) -> Due:
279
+ """Pure decision for one scheduler tick (now is local, tz-aware).
280
+
281
+ - before today's slot: nothing;
282
+ - today's brief already run (on time or caught up): nothing;
283
+ - otherwise run once. Late if more than LATE_GRACE after the slot: cause
284
+ "asleep" when this process ticked before the slot and then not for a
285
+ while (the Mac slept), "not_running" when this process wasn't up.
286
+ - days between the last brief and today whose slot passed with no brief
287
+ are "missed": today's brief says so; they are never run late."""
288
+ slot = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
289
+ today = now.date()
290
+ if now < slot or last_run == today:
291
+ return Due(False, slot=slot)
292
+ late = now - slot > LATE_GRACE
293
+ cause = ""
294
+ if late:
295
+ if prev_tick is not None and prev_tick < slot and now - prev_tick > SLEEP_GAP:
296
+ cause = "asleep"
297
+ elif prev_tick is None:
298
+ cause = "not_running"
299
+ start = last_run + timedelta(days=1) if last_run else today
300
+ first = first_day(scheduled_at, hh, mm)
301
+ if first and first > start:
302
+ start = first
303
+ missed = [start + timedelta(days=i) for i in range((today - start).days)] \
304
+ if start < today else []
305
+ return Due(True, late=late, cause=cause, missed=missed, slot=slot)
306
+
307
+
308
+ def opening_lines(due: Due, now: datetime) -> List[str]:
309
+ lines = []
310
+ if due.late and due.slot is not None:
311
+ at, ran = _fmt_hm(due.slot), _fmt_hm(now)
312
+ if due.cause == "asleep":
313
+ lines.append(f"This brief is late: your Mac was asleep at {at}, so it ran when you "
314
+ f"opened it at {ran}.")
315
+ elif due.cause == "not_running":
316
+ lines.append(f"This brief is late: HyperAgents wasn't running at {at} (the Mac was "
317
+ f"off or asleep, or HyperAgents was stopped), so it ran when it started "
318
+ f"at {ran}.")
319
+ else:
320
+ lines.append(f"This brief is late: it was due at {at} and ran at {ran}.")
321
+ if due.missed:
322
+ days = ", ".join(_fmt_day(d) for d in due.missed)
323
+ n = len(due.missed)
324
+ lines.append(f"{'A day was' if n == 1 else f'{n} days were'} missed: there was no brief "
325
+ f"on {days}. This is today's brief only.")
326
+ return lines
327
+
328
+
329
+ class Ticker:
330
+ """The daily_brief job body: called every minute by the scheduler.
331
+ ``prev_tick`` lives in memory so a restart is told apart from a sleep."""
332
+
333
+ def __init__(self, *, clock: Optional[Callable[[], datetime]] = None,
334
+ send: Optional[Callable] = None, run_kw: Optional[dict] = None):
335
+ self.clock = clock
336
+ self.send = send
337
+ self.run_kw = run_kw or {}
338
+ self.prev_tick: Optional[datetime] = None
339
+ self._lock = asyncio.Lock()
340
+
341
+ def _now(self) -> datetime:
342
+ return self.clock() if self.clock else datetime.now(tz())
343
+
344
+ async def tick(self) -> Optional[Outcome]:
345
+ async with self._lock:
346
+ now = self._now()
347
+ prev, self.prev_tick = self.prev_tick, now
348
+ st = store.load_state()
349
+ hh, mm = store.run_time()
350
+ last_run = _parse_date(st.get("last_run_date"))
351
+ sched_at = _parse_dt(store.config().get("scheduled_at"))
352
+ due = decide(now, hh, mm, last_run=last_run, prev_tick=prev, scheduled_at=sched_at)
353
+ if not due.run:
354
+ return None
355
+ # claim the day first: at most one brief (or catch-up) per day
356
+ store.update_state(last_run_date=now.date().isoformat())
357
+ for d in due.missed:
358
+ activity("missed", f"no brief on {d.isoformat()}: the slot passed with "
359
+ f"no run; replaced by today's brief")
360
+ opening = opening_lines(due, now)
361
+ try:
362
+ outcome = await asyncio.to_thread(
363
+ lambda: run_brief_with_day(now=now, opening=opening, **self.run_kw))
364
+ except Exception as e:
365
+ log.error("brief: job failed (%s)", type(e).__name__, exc_info=True)
366
+ outcome = Outcome(False, "\n".join(opening + [failure_notice(Reason.INTERNAL)]),
367
+ Reason.INTERNAL.value)
368
+ if due.late:
369
+ activity("caught_up" if outcome.ok else "failed",
370
+ f"{_fmt_hm(due.slot)} brief ran late at {_fmt_hm(now)} "
371
+ f"({due.cause or 'late'})" + ("" if outcome.ok else
372
+ f"; not written: {outcome.reason}"))
373
+ else:
374
+ activity("ok" if outcome.ok else "failed",
375
+ f"{_fmt_hm(due.slot)} brief" + ("" if outcome.ok else
376
+ f"; not written: {outcome.reason}"))
377
+ await deliver(outcome, self.send)
378
+ return outcome
379
+
380
+
381
+ def _parse_date(v) -> Optional[date]:
382
+ try:
383
+ return date.fromisoformat(str(v)) if v else None
384
+ except ValueError:
385
+ return None
386
+
387
+
388
+ def _parse_dt(v) -> Optional[datetime]:
389
+ try:
390
+ return datetime.fromisoformat(str(v)) if v else None
391
+ except ValueError:
392
+ return None
393
+
394
+
395
+ # ── schedule gate / status ────────────────────────────────────────────────
396
+
397
+ def named_in_env() -> bool:
398
+ raw = os.environ.get("HYPERCLAW_SCHEDULER_JOBS", "")
399
+ return store.JOB_ID in {n.strip().lower() for n in raw.split(",")}
400
+
401
+
402
+ def opted_in() -> bool:
403
+ return named_in_env() or store.opted_in_by_config()
404
+
405
+
406
+ def status() -> dict:
407
+ from hyperclaw import unattended
408
+ st = store.load_state()
409
+ srcs = store.sources()
410
+ hh, mm = store.run_time()
411
+ return {
412
+ "doc": bool((srcs.get("doc") or {}).get("id")),
413
+ "sheet": bool((srcs.get("sheet") or {}).get("id")),
414
+ "doc_title": (srcs.get("doc") or {}).get("title", ""),
415
+ "sheet_title": (srcs.get("sheet") or {}).get("title", ""),
416
+ "google": google_auth.connected(),
417
+ "unattended": unattended.enabled(),
418
+ "opted_in": opted_in(),
419
+ "time": f"{hh:02d}:{mm:02d}",
420
+ "tz": store.tz_name(),
421
+ "last_run_date": st.get("last_run_date", ""),
422
+ "last_result": st.get("last_result") or {},
423
+ "latest_path": str(store.latest_path()),
424
+ }
@@ -0,0 +1,205 @@
1
+ """Owner-only local files for the brief, all under $HYPERCLAW_ROOT/brief
2
+ (default ~/.hyperclaw/brief, directory 0700, every file 0600):
3
+
4
+ - ``sources.json`` the master Doc and to-do Sheet (file ids only, never the
5
+ link) and the titles last read from Google;
6
+ - ``google-token.json`` the Google refresh/access token (read-only scopes);
7
+ - ``snapshot.json`` yesterday's comparison copy: ONE file, replaced by the
8
+ first brief of each day, never uploaded, never logged;
9
+ - ``state.json`` schedule bookkeeping (last scheduled run, last tick);
10
+ - ``latest.txt`` the last brief, for the terminal.
11
+
12
+ The schedule itself (time, timezone, opted in) is non-secret and lives in
13
+ config.json under ``"brief"`` so the scheduler can read it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import stat
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from hyperclaw.userconfig import load_config, root, update_config
24
+
25
+ JOB_ID = "daily_brief"
26
+ DEFAULT_TIME = "06:00"
27
+
28
+
29
+ def brief_dir() -> Path:
30
+ return root() / "brief"
31
+
32
+
33
+ def sources_path() -> Path:
34
+ return brief_dir() / "sources.json"
35
+
36
+
37
+ def token_path() -> Path:
38
+ return brief_dir() / "google-token.json"
39
+
40
+
41
+ def snapshot_path() -> Path:
42
+ return brief_dir() / "snapshot.json"
43
+
44
+
45
+ def state_path() -> Path:
46
+ return brief_dir() / "state.json"
47
+
48
+
49
+ def latest_path() -> Path:
50
+ return brief_dir() / "latest.txt"
51
+
52
+
53
+ def _ensure_dir() -> Path:
54
+ d = brief_dir()
55
+ d.mkdir(parents=True, exist_ok=True)
56
+ try:
57
+ os.chmod(d, 0o700)
58
+ except OSError:
59
+ pass
60
+ return d
61
+
62
+
63
+ def write_private(p: Path, text: str) -> None:
64
+ """Atomic write, owner-only (0600) from the first byte."""
65
+ _ensure_dir()
66
+ p.parent.mkdir(parents=True, exist_ok=True)
67
+ tmp = p.with_name(p.name + ".tmp")
68
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
69
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
70
+ f.write(text)
71
+ os.chmod(tmp, 0o600)
72
+ os.replace(tmp, p)
73
+
74
+
75
+ def too_open(p: Path) -> bool:
76
+ try:
77
+ return bool(p.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO))
78
+ except FileNotFoundError:
79
+ return False
80
+
81
+
82
+ def read_json(p: Path) -> dict:
83
+ try:
84
+ data = json.loads(p.read_text(encoding="utf-8"))
85
+ return data if isinstance(data, dict) else {}
86
+ except Exception:
87
+ return {}
88
+
89
+
90
+ def write_json(p: Path, data: dict) -> None:
91
+ write_private(p, json.dumps(data, indent=2, ensure_ascii=False))
92
+
93
+
94
+ # ── sources ───────────────────────────────────────────────────────────────
95
+
96
+ def sources() -> dict:
97
+ """{"doc": {"id", "title"}, "sheet": {"id", "title"}} (either may be absent)."""
98
+ return read_json(sources_path())
99
+
100
+
101
+ def set_source(kind: str, file_id: str, title: str = "") -> None:
102
+ data = sources()
103
+ data[kind] = {"id": file_id, "title": title}
104
+ write_json(sources_path(), data)
105
+
106
+
107
+ def set_title(kind: str, title: str) -> None:
108
+ data = sources()
109
+ if kind in data:
110
+ data[kind]["title"] = title
111
+ write_json(sources_path(), data)
112
+
113
+
114
+ def source_id(kind: str) -> str:
115
+ return str((sources().get(kind) or {}).get("id") or "")
116
+
117
+
118
+ def clear_sources() -> bool:
119
+ try:
120
+ sources_path().unlink()
121
+ return True
122
+ except FileNotFoundError:
123
+ return False
124
+
125
+
126
+ # ── snapshot (yesterday's comparison copy) ────────────────────────────────
127
+
128
+ def load_snapshot() -> Optional[dict]:
129
+ p = snapshot_path()
130
+ if not p.exists():
131
+ return None
132
+ data = read_json(p)
133
+ return data or None
134
+
135
+
136
+ def save_snapshot(data: dict) -> None:
137
+ """Replace the one snapshot. Never a history: the same path every day."""
138
+ write_json(snapshot_path(), data)
139
+
140
+
141
+ # ── run state ─────────────────────────────────────────────────────────────
142
+
143
+ def load_state() -> dict:
144
+ return read_json(state_path())
145
+
146
+
147
+ def save_state(data: dict) -> None:
148
+ write_json(state_path(), data)
149
+
150
+
151
+ def update_state(**kw) -> dict:
152
+ data = load_state()
153
+ for k, v in kw.items():
154
+ if v is None:
155
+ data.pop(k, None)
156
+ else:
157
+ data[k] = v
158
+ save_state(data)
159
+ return data
160
+
161
+
162
+ def save_latest(text: str) -> None:
163
+ write_private(latest_path(), text)
164
+
165
+
166
+ # ── schedule config (config.json "brief") ─────────────────────────────────
167
+
168
+ def config() -> dict:
169
+ d = load_config().get("brief")
170
+ return d if isinstance(d, dict) else {}
171
+
172
+
173
+ def set_config(**kw) -> dict:
174
+ d = config()
175
+ for k, v in kw.items():
176
+ if v is None:
177
+ d.pop(k, None)
178
+ else:
179
+ d[k] = v
180
+ update_config(brief=d)
181
+ return d
182
+
183
+
184
+ def opted_in_by_config() -> bool:
185
+ return config().get("scheduled") is True
186
+
187
+
188
+ def run_time() -> tuple:
189
+ """(hour, minute) of the daily brief; config brief.time "HH:MM"."""
190
+ parsed = parse_hhmm(str(config().get("time") or DEFAULT_TIME))
191
+ return parsed or parse_hhmm(DEFAULT_TIME)
192
+
193
+
194
+ def parse_hhmm(raw: str) -> Optional[tuple]:
195
+ try:
196
+ h, m = (int(x) for x in raw.strip().split(":", 1))
197
+ except (ValueError, AttributeError):
198
+ return None
199
+ if 0 <= h < 24 and 0 <= m < 60:
200
+ return h, m
201
+ return None
202
+
203
+
204
+ def tz_name() -> str:
205
+ return str(config().get("tz") or "")