hyperagents 1.2.3rc5.post3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
|
@@ -0,0 +1,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
|
+
}
|
hyperclaw/brief/store.py
ADDED
|
@@ -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 "")
|