nexo-brain 7.28.0 → 7.30.0

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.
@@ -38,8 +38,12 @@ import signal
38
38
  import subprocess
39
39
  import sys
40
40
  import tempfile
41
+ import urllib.parse
42
+ import urllib.request
43
+ import xml.etree.ElementTree as ET
41
44
  from datetime import date, datetime
42
45
  from pathlib import Path
46
+ from typing import Any
43
47
 
44
48
  _script_dir = Path(__file__).resolve().parent
45
49
  _repo_src = _script_dir.parent
@@ -47,6 +51,7 @@ if str(_repo_src) not in sys.path:
47
51
  sys.path.insert(0, str(_repo_src))
48
52
 
49
53
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
54
+ from automation_preferences import format_automation_preferences_prompt_block, get_automation_preferences
50
55
  from automation_controls import (
51
56
  format_operator_extra_instructions_block,
52
57
  get_operator_briefing_recipient_status,
@@ -56,9 +61,16 @@ from automation_controls import (
56
61
  )
57
62
  from client_preferences import resolve_automation_backend, resolve_client_runtime_profile
58
63
  from core_prompts import render_core_prompt
64
+ from email_presentation import build_email_presentation, normalize_agent_email_payload
59
65
  from email_sent_events import format_recent_sent_email_block, recent_sent_emails
66
+ from morning_briefing import (
67
+ LATEST_MARKDOWN_FILE,
68
+ ensure_morning_briefing_runs_table as _ensure_briefing_schema,
69
+ mark_morning_briefing_sent as _persist_morning_briefing_sent,
70
+ write_latest_briefing_artifacts,
71
+ )
60
72
  import db as nexo_db
61
- from paths import data_dir, logs_dir, operations_dir
73
+ from paths import data_dir, logs_dir
62
74
  from runtime_home import export_resolved_nexo_home
63
75
 
64
76
  NEXO_HOME = export_resolved_nexo_home()
@@ -66,7 +78,7 @@ LOG_DIR = logs_dir()
66
78
  LOG_DIR.mkdir(parents=True, exist_ok=True)
67
79
  LOG_FILE = LOG_DIR / "morning-agent.log"
68
80
  STATE_FILE = data_dir() / "morning-agent-state.json"
69
- LATEST_BRIEFING_FILE = operations_dir() / "morning-briefing-latest.md"
81
+ LATEST_BRIEFING_FILE = LATEST_MARKDOWN_FILE
70
82
  CALLER = "morning_agent"
71
83
  CLI_TIMEOUT = 1500
72
84
  MAX_DUE_ITEMS = 8
@@ -74,6 +86,8 @@ MAX_ACTIVE_ITEMS = 8
74
86
  MAX_DIARY_ITEMS = 6
75
87
  MORNING_BRIEFING_STALE_HOURS = 12
76
88
  _ACTIVE_CLAIM: dict[str, str] = {}
89
+ HTTP_TIMEOUT = 7
90
+ DEFAULT_NEWS_RSS_URL = "https://news.google.com/rss?hl=es&gl=ES&ceid=ES:es"
77
91
 
78
92
 
79
93
  def log(message: str) -> None:
@@ -105,29 +119,7 @@ def _morning_db_connection():
105
119
 
106
120
 
107
121
  def _ensure_morning_briefing_runs_table(conn) -> None:
108
- conn.execute(
109
- """CREATE TABLE IF NOT EXISTS morning_briefing_runs (
110
- id INTEGER PRIMARY KEY AUTOINCREMENT,
111
- local_date TEXT NOT NULL,
112
- recipient TEXT NOT NULL,
113
- status TEXT NOT NULL DEFAULT 'in_progress',
114
- subject TEXT DEFAULT '',
115
- send_output TEXT DEFAULT '',
116
- error TEXT DEFAULT '',
117
- started_at TEXT DEFAULT (datetime('now')),
118
- finished_at TEXT DEFAULT NULL,
119
- updated_at TEXT DEFAULT (datetime('now')),
120
- UNIQUE(local_date, recipient)
121
- )"""
122
- )
123
- conn.execute(
124
- "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_date "
125
- "ON morning_briefing_runs(local_date)"
126
- )
127
- conn.execute(
128
- "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_status "
129
- "ON morning_briefing_runs(status)"
130
- )
122
+ _ensure_briefing_schema(conn)
131
123
 
132
124
 
133
125
  def _row_dict(row) -> dict:
@@ -189,8 +181,14 @@ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool
189
181
  ON CONFLICT(local_date, recipient) DO UPDATE SET
190
182
  status = 'in_progress',
191
183
  subject = '',
184
+ body_text = '',
185
+ body_html = '',
186
+ artifact_json = '',
192
187
  send_output = '',
193
188
  error = '',
189
+ desktop_shown_at = NULL,
190
+ desktop_opened_at = NULL,
191
+ desktop_dismissed_at = NULL,
194
192
  started_at = excluded.started_at,
195
193
  finished_at = NULL,
196
194
  updated_at = excluded.updated_at
@@ -226,8 +224,14 @@ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool
226
224
  UPDATE morning_briefing_runs
227
225
  SET status = 'in_progress',
228
226
  subject = '',
227
+ body_text = '',
228
+ body_html = '',
229
+ artifact_json = '',
229
230
  send_output = '',
230
231
  error = '',
232
+ desktop_shown_at = NULL,
233
+ desktop_opened_at = NULL,
234
+ desktop_dismissed_at = NULL,
231
235
  started_at = ?,
232
236
  finished_at = NULL,
233
237
  updated_at = ?
@@ -268,24 +272,25 @@ def _record_existing_morning_briefing_sent(local_date: str, recipient: str, stat
268
272
  conn.commit()
269
273
 
270
274
 
271
- def _mark_morning_briefing_sent(local_date: str, recipient: str, *, subject: str, send_output: str) -> None:
272
- now = datetime.now().astimezone().isoformat()
273
- conn = _morning_db_connection()
274
- _ensure_morning_briefing_runs_table(conn)
275
- conn.execute(
276
- """
277
- UPDATE morning_briefing_runs
278
- SET status = 'sent',
279
- subject = ?,
280
- send_output = ?,
281
- error = '',
282
- finished_at = ?,
283
- updated_at = ?
284
- WHERE local_date = ? AND recipient = ?
285
- """,
286
- (str(subject or ""), str(send_output or ""), now, now, str(local_date or ""), str(recipient or "")),
275
+ def _mark_morning_briefing_sent(
276
+ local_date: str,
277
+ recipient: str,
278
+ *,
279
+ subject: str,
280
+ body_text: str,
281
+ body_html: str,
282
+ send_output: str,
283
+ artifact_payload: dict | None = None,
284
+ ) -> None:
285
+ _persist_morning_briefing_sent(
286
+ local_date=local_date,
287
+ recipient=recipient,
288
+ subject=subject,
289
+ body_text=body_text,
290
+ body_html=body_html,
291
+ send_output=send_output,
292
+ artifact_payload=artifact_payload,
287
293
  )
288
- conn.commit()
289
294
 
290
295
 
291
296
  def _mark_morning_briefing_failed(local_date: str, recipient: str, *, error: str) -> None:
@@ -448,7 +453,250 @@ def _serialize_recent_sent_emails(*, limit: int = 8) -> list[dict]:
448
453
  return result
449
454
 
450
455
 
451
- def collect_context(profile: dict) -> dict:
456
+ def _fetch_json_url(url: str, *, timeout: int = HTTP_TIMEOUT) -> dict:
457
+ request = urllib.request.Request(
458
+ url,
459
+ headers={"User-Agent": "NEXO-Morning-Agent/1.0"},
460
+ )
461
+ with urllib.request.urlopen(request, timeout=timeout) as response:
462
+ raw = response.read(512_000)
463
+ payload = json.loads(raw.decode("utf-8", errors="replace"))
464
+ return payload if isinstance(payload, dict) else {}
465
+
466
+
467
+ def _fetch_text_url(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
468
+ request = urllib.request.Request(
469
+ url,
470
+ headers={"User-Agent": "NEXO-Morning-Agent/1.0"},
471
+ )
472
+ with urllib.request.urlopen(request, timeout=timeout) as response:
473
+ return response.read(512_000).decode("utf-8", errors="replace")
474
+
475
+
476
+ def _desktop_settings_candidates() -> list[Path]:
477
+ home = Path.home()
478
+ candidates = [
479
+ Path(os.environ.get("NEXO_DESKTOP_SETTINGS", "")),
480
+ Path(os.environ.get("NEXO_DESKTOP_USER_DATA", "")) / "app-settings.json",
481
+ home / "Library" / "Application Support" / "nexo-desktop-mvp" / "app-settings.json",
482
+ home / "Library" / "Application Support" / "NEXO Desktop" / "app-settings.json",
483
+ home / "Library" / "Application Support" / "nexo-desktop" / "app-settings.json",
484
+ ]
485
+ return [candidate for candidate in candidates if str(candidate)]
486
+
487
+
488
+ def _read_desktop_settings() -> dict:
489
+ for candidate in _desktop_settings_candidates():
490
+ try:
491
+ if candidate.is_file():
492
+ payload = json.loads(candidate.read_text())
493
+ if isinstance(payload, dict):
494
+ return payload
495
+ except Exception:
496
+ continue
497
+ return {}
498
+
499
+
500
+ def _normalize_location_candidate(value: object) -> dict:
501
+ if not value:
502
+ return {}
503
+ candidate = value
504
+ if isinstance(value, str):
505
+ text = value.strip()
506
+ if not text:
507
+ return {}
508
+ try:
509
+ candidate = json.loads(text)
510
+ except Exception:
511
+ return {"name": text}
512
+ if not isinstance(candidate, dict):
513
+ return {}
514
+ lat = candidate.get("lat", candidate.get("latitude"))
515
+ lon = candidate.get("lon", candidate.get("longitude"))
516
+ name = str(candidate.get("name") or candidate.get("display") or candidate.get("city") or "").strip()
517
+ try:
518
+ lat_value = float(lat)
519
+ lon_value = float(lon)
520
+ except Exception:
521
+ lat_value = None
522
+ lon_value = None
523
+ if lat_value is not None and lon_value is not None:
524
+ return {"lat": lat_value, "lon": lon_value, "name": name}
525
+ return {"name": name} if name else {}
526
+
527
+
528
+ def _geocode_location_name(name: str, *, language: str = "es") -> dict:
529
+ clean = str(name or "").strip()
530
+ if not clean:
531
+ return {}
532
+ params = urllib.parse.urlencode({
533
+ "name": clean,
534
+ "count": "1",
535
+ "language": "en" if str(language).lower().startswith("en") else "es",
536
+ "format": "json",
537
+ })
538
+ payload = _fetch_json_url(f"https://geocoding-api.open-meteo.com/v1/search?{params}")
539
+ hit = (payload.get("results") or [None])[0]
540
+ if not isinstance(hit, dict):
541
+ return {}
542
+ try:
543
+ lat = float(hit.get("latitude"))
544
+ lon = float(hit.get("longitude"))
545
+ except Exception:
546
+ return {}
547
+ label_parts = [
548
+ str(hit.get("name") or clean).strip(),
549
+ str(hit.get("admin1") or "").strip(),
550
+ str(hit.get("country") or "").strip(),
551
+ ]
552
+ return {
553
+ "lat": lat,
554
+ "lon": lon,
555
+ "name": ", ".join(part for part in label_parts if part),
556
+ }
557
+
558
+
559
+ def _resolve_weather_location(profile: dict) -> dict:
560
+ settings = _read_desktop_settings()
561
+ app = settings.get("app") if isinstance(settings.get("app"), dict) else {}
562
+ app_loc = app.get("location") if isinstance(app.get("location"), dict) else {}
563
+ language = str(app.get("ui_language") or profile.get("language") or "es")
564
+
565
+ explicit = _normalize_location_candidate(app_loc)
566
+ if explicit.get("lat") is not None and explicit.get("lon") is not None:
567
+ return explicit
568
+ if explicit.get("name"):
569
+ try:
570
+ geocoded = _geocode_location_name(str(explicit.get("name")), language=language)
571
+ if geocoded:
572
+ return geocoded
573
+ except Exception:
574
+ pass
575
+
576
+ desktop_profile = settings.get("profile") if isinstance(settings.get("profile"), dict) else {}
577
+ for source in [
578
+ desktop_profile.get("current_residence"),
579
+ profile.get("current_residence"),
580
+ profile.get("location"),
581
+ profile.get("coordinates"),
582
+ ]:
583
+ candidate = _normalize_location_candidate(source)
584
+ if candidate.get("lat") is not None and candidate.get("lon") is not None:
585
+ return candidate
586
+ if candidate.get("name"):
587
+ try:
588
+ geocoded = _geocode_location_name(str(candidate.get("name")), language=language)
589
+ if geocoded:
590
+ return geocoded
591
+ except Exception:
592
+ continue
593
+
594
+ direct = _normalize_location_candidate({
595
+ "latitude": profile.get("latitude"),
596
+ "longitude": profile.get("longitude"),
597
+ "name": profile.get("current_residence") or "",
598
+ })
599
+ return direct
600
+
601
+
602
+ def _weather_code_label(code: object) -> str:
603
+ try:
604
+ value = int(code)
605
+ except Exception:
606
+ return ""
607
+ if value == 0:
608
+ return "clear"
609
+ if value in {1, 2, 3}:
610
+ return "partly cloudy"
611
+ if value in {45, 48}:
612
+ return "fog"
613
+ if value in {51, 53, 55, 56, 57}:
614
+ return "drizzle"
615
+ if value in {61, 63, 65, 66, 67, 80, 81, 82}:
616
+ return "rain"
617
+ if value in {71, 73, 75, 77, 85, 86}:
618
+ return "snow"
619
+ if value in {95, 96, 99}:
620
+ return "storm"
621
+ return "unknown"
622
+
623
+
624
+ def _collect_weather(profile: dict) -> dict:
625
+ try:
626
+ loc = _resolve_weather_location(profile)
627
+ if not loc or loc.get("lat") is None or loc.get("lon") is None:
628
+ return {"available": False, "error": "no_location"}
629
+ params = urllib.parse.urlencode({
630
+ "latitude": loc["lat"],
631
+ "longitude": loc["lon"],
632
+ "current_weather": "true",
633
+ "daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max",
634
+ "timezone": "auto",
635
+ })
636
+ payload = _fetch_json_url(f"https://api.open-meteo.com/v1/forecast?{params}")
637
+ current = payload.get("current_weather") if isinstance(payload.get("current_weather"), dict) else {}
638
+ daily = payload.get("daily") if isinstance(payload.get("daily"), dict) else {}
639
+ if not current:
640
+ return {"available": False, "error": "weather_unavailable", "location": loc.get("name") or ""}
641
+ code = current.get("weathercode")
642
+ return {
643
+ "available": True,
644
+ "source": "open-meteo",
645
+ "location": loc.get("name") or "",
646
+ "temperature_c": current.get("temperature"),
647
+ "weather_code": code,
648
+ "weather": _weather_code_label(code),
649
+ "high_c": (daily.get("temperature_2m_max") or [None])[0],
650
+ "low_c": (daily.get("temperature_2m_min") or [None])[0],
651
+ "precipitation_probability_max": (daily.get("precipitation_probability_max") or [None])[0],
652
+ }
653
+ except Exception as exc:
654
+ return {"available": False, "error": str(exc)[:240]}
655
+
656
+
657
+ def _collect_news(profile: dict) -> dict:
658
+ try:
659
+ rss_url = str(profile.get("news_rss_url") or os.environ.get("NEXO_NEWS_RSS_URL") or DEFAULT_NEWS_RSS_URL).strip()
660
+ if not rss_url:
661
+ return {"available": False, "error": "no_news_source"}
662
+ xml_text = _fetch_text_url(rss_url)
663
+ root = ET.fromstring(xml_text)
664
+ items: list[dict] = []
665
+ for item in root.findall(".//item"):
666
+ title = _clean_text(item.findtext("title"), limit=220)
667
+ link = str(item.findtext("link") or "").strip()
668
+ source = _clean_text(item.findtext("source"), limit=80)
669
+ published = _clean_text(item.findtext("pubDate"), limit=120)
670
+ if title:
671
+ items.append({
672
+ "title": title,
673
+ "source": source,
674
+ "published": published,
675
+ "url": link[:500],
676
+ })
677
+ if len(items) >= 6:
678
+ break
679
+ return {
680
+ "available": bool(items),
681
+ "source": rss_url,
682
+ "headlines": items,
683
+ "error": "" if items else "empty_feed",
684
+ }
685
+ except Exception as exc:
686
+ return {"available": False, "error": str(exc)[:240], "headlines": []}
687
+
688
+
689
+ def _collect_external_context(profile: dict, preferences: dict | None) -> dict:
690
+ values = preferences if isinstance(preferences, dict) else {}
691
+ external: dict[str, Any] = {}
692
+ if values.get("weather"):
693
+ external["weather"] = _collect_weather(profile)
694
+ if values.get("news"):
695
+ external["news"] = _collect_news(profile)
696
+ return external
697
+
698
+
699
+ def collect_context(profile: dict, preferences: dict | None = None) -> dict:
452
700
  nexo_db.init_db()
453
701
  due_followups = _serialize_followups("due", limit=MAX_DUE_ITEMS)
454
702
  due_followup_ids = {row["id"] for row in due_followups}
@@ -465,6 +713,7 @@ def collect_context(profile: dict) -> dict:
465
713
  if row["id"] not in due_reminder_ids
466
714
  ][:MAX_ACTIVE_ITEMS]
467
715
  recent_sent = _serialize_recent_sent_emails()
716
+ external = _collect_external_context(profile, preferences)
468
717
  return {
469
718
  "generated_at": datetime.now().astimezone().isoformat(),
470
719
  "today": date.today().isoformat(),
@@ -472,6 +721,9 @@ def collect_context(profile: dict) -> dict:
472
721
  "name": str(profile.get("operator_name") or "the operator"),
473
722
  "language": str(profile.get("language") or "en"),
474
723
  "email": str(profile.get("operator_email") or ""),
724
+ "role": str(profile.get("role") or ""),
725
+ "technical_level": str(profile.get("technical_level") or ""),
726
+ "residence": str(profile.get("current_residence") or ""),
475
727
  },
476
728
  "assistant": {
477
729
  "name": str(profile.get("assistant_name") or "Nova"),
@@ -482,6 +734,7 @@ def collect_context(profile: dict) -> dict:
482
734
  "active_followups": active_followups,
483
735
  "recent_diaries": _serialize_diaries(limit=MAX_DIARY_ITEMS),
484
736
  "recent_sent_emails_24h": recent_sent,
737
+ "external": external,
485
738
  "counts": {
486
739
  "due_reminders": len(due_reminders),
487
740
  "active_reminders": len(active_reminders),
@@ -548,7 +801,7 @@ def _extract_json_payload(raw_text: str) -> dict:
548
801
  raise RuntimeError("Morning agent returned invalid JSON output.")
549
802
 
550
803
 
551
- def generate_briefing(prompt: str) -> tuple[str, str]:
804
+ def generate_briefing(prompt: str):
552
805
  backend = resolve_automation_backend()
553
806
  profile = resolve_client_runtime_profile(backend) if backend != "none" else {"model": "", "reasoning_effort": ""}
554
807
  profile_label = profile.get("model") or "default"
@@ -576,33 +829,42 @@ def generate_briefing(prompt: str) -> tuple[str, str]:
576
829
  raise RuntimeError(detail or f"automation backend exited {result.returncode}")
577
830
 
578
831
  payload = _extract_json_payload(result.stdout or "")
579
- subject = str(payload.get("subject") or "").strip()
580
- body = str(payload.get("body") or "").strip()
581
- if not subject or not body:
582
- raise RuntimeError("Morning agent output is missing subject/body.")
583
- return subject, body
584
-
585
-
586
- def write_latest_briefing(*, recipient: str, subject: str, body: str) -> None:
587
- LATEST_BRIEFING_FILE.parent.mkdir(parents=True, exist_ok=True)
588
- rendered = (
589
- f"# Morning briefing\n\n"
590
- f"- Generated at: {datetime.now().astimezone().isoformat()}\n"
591
- f"- To: {recipient}\n"
592
- f"- Subject: {subject}\n\n"
593
- f"{body}\n"
832
+ try:
833
+ return normalize_agent_email_payload(payload)
834
+ except RuntimeError as exc:
835
+ raise RuntimeError("Morning agent output is missing subject/body.") from exc
836
+
837
+
838
+ def write_latest_briefing(
839
+ *,
840
+ recipient: str,
841
+ subject: str,
842
+ body_text: str,
843
+ body_html: str,
844
+ local_date: str = "",
845
+ run_id: int | None = None,
846
+ ) -> dict:
847
+ return write_latest_briefing_artifacts(
848
+ recipient=recipient,
849
+ subject=subject,
850
+ body_text=body_text,
851
+ body_html=body_html,
852
+ local_date=local_date,
853
+ run_id=run_id,
594
854
  )
595
- LATEST_BRIEFING_FILE.write_text(rendered, encoding="utf-8")
596
855
 
597
856
 
598
- def send_briefing(*, recipient: str, subject: str, body: str) -> str:
857
+ def send_briefing(*, recipient: str, subject: str, body_text: str, body_html: str) -> str:
599
858
  sender = get_send_reply_script_path(local_script_dir=_script_dir)
600
859
  if not sender.exists():
601
860
  raise RuntimeError(f"nexo-send-reply.py not found at {sender}")
602
861
 
603
862
  tmp_fd, tmp_path = tempfile.mkstemp(prefix="morning-briefing-", suffix=".txt")
604
863
  os.close(tmp_fd)
605
- Path(tmp_path).write_text(body, encoding="utf-8")
864
+ html_fd, html_path = tempfile.mkstemp(prefix="morning-briefing-", suffix=".html")
865
+ os.close(html_fd)
866
+ Path(tmp_path).write_text(body_text, encoding="utf-8")
867
+ Path(html_path).write_text(body_html, encoding="utf-8")
606
868
  try:
607
869
  result = subprocess.run(
608
870
  [
@@ -614,6 +876,12 @@ def send_briefing(*, recipient: str, subject: str, body: str) -> str:
614
876
  subject,
615
877
  "--body-file",
616
878
  tmp_path,
879
+ "--html-file",
880
+ html_path,
881
+ "--audience",
882
+ "operator",
883
+ "--message-kind",
884
+ "morning_briefing",
617
885
  ],
618
886
  capture_output=True,
619
887
  text=True,
@@ -621,6 +889,7 @@ def send_briefing(*, recipient: str, subject: str, body: str) -> str:
621
889
  )
622
890
  finally:
623
891
  Path(tmp_path).unlink(missing_ok=True)
892
+ Path(html_path).unlink(missing_ok=True)
624
893
 
625
894
  if result.returncode != 0:
626
895
  detail = (result.stderr or result.stdout or "").strip()
@@ -667,28 +936,68 @@ def main(argv: list[str] | None = None) -> int:
667
936
  _set_active_claim(today, recipient)
668
937
 
669
938
  try:
670
- context = collect_context(profile)
939
+ preference_contract = get_automation_preferences("morning-agent")
940
+ preference_values = (
941
+ (preference_contract.get("preferences") or {}).get("values")
942
+ if isinstance(preference_contract, dict)
943
+ else {}
944
+ )
945
+ context = collect_context(profile, preference_values if isinstance(preference_values, dict) else {})
946
+ extra_blocks = "\n".join(
947
+ block
948
+ for block in [
949
+ format_automation_preferences_prompt_block("morning-agent"),
950
+ format_operator_extra_instructions_block("morning-agent"),
951
+ ]
952
+ if block.strip()
953
+ )
671
954
  prompt = build_prompt(
672
955
  context,
673
- extra_instructions_block=format_operator_extra_instructions_block("morning-agent"),
956
+ extra_instructions_block=extra_blocks,
957
+ )
958
+ presentation = generate_briefing(prompt)
959
+ body_text = append_recent_sent_email_block(presentation.body_text)
960
+ if body_text != presentation.body_text:
961
+ presentation = build_email_presentation(subject=presentation.subject, body_text=body_text)
962
+ artifact_payload = write_latest_briefing(
963
+ recipient=recipient or "[dry-run]",
964
+ subject=presentation.subject,
965
+ body_text=presentation.body_text,
966
+ body_html=presentation.body_html,
967
+ local_date=today,
674
968
  )
675
- subject, body = generate_briefing(prompt)
676
- body = append_recent_sent_email_block(body)
677
- write_latest_briefing(recipient=recipient or "[dry-run]", subject=subject, body=body)
678
969
 
679
970
  if args.dry_run:
680
- print(json.dumps({"subject": subject, "body": body}, indent=2, ensure_ascii=False))
971
+ print(json.dumps({
972
+ "subject": presentation.subject,
973
+ "body": presentation.body_text,
974
+ "body_text": presentation.body_text,
975
+ "body_html": presentation.body_html,
976
+ }, indent=2, ensure_ascii=False))
681
977
  return 0
682
978
 
683
979
  log(f"Sending morning briefing to {recipient}...")
684
- send_output = send_briefing(recipient=recipient, subject=subject, body=body)
685
- _mark_morning_briefing_sent(today, recipient, subject=subject, send_output=send_output)
980
+ send_output = send_briefing(
981
+ recipient=recipient,
982
+ subject=presentation.subject,
983
+ body_text=presentation.body_text,
984
+ body_html=presentation.body_html,
985
+ )
986
+ _mark_morning_briefing_sent(
987
+ today,
988
+ recipient,
989
+ subject=presentation.subject,
990
+ body_text=presentation.body_text,
991
+ body_html=presentation.body_html,
992
+ send_output=send_output,
993
+ artifact_payload=artifact_payload,
994
+ )
686
995
  _clear_active_claim()
687
996
  save_state({
688
997
  "last_sent_date": today,
689
998
  "last_sent_at": datetime.now().astimezone().isoformat(),
690
999
  "last_recipient": recipient,
691
- "last_subject": subject,
1000
+ "last_subject": presentation.subject,
692
1001
  "last_send_output": send_output,
693
1002
  })
694
1003
  log("Morning briefing sent.")