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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +72 -0
- package/src/automation_controls.py +187 -10
- package/src/automation_preferences.py +367 -0
- package/src/cli.py +157 -0
- package/src/cli_email.py +95 -0
- package/src/cron_recovery.py +58 -3
- package/src/crons/sync.py +47 -14
- package/src/db/_schema.py +18 -0
- package/src/email_presentation.py +243 -0
- package/src/model_defaults.json +4 -4
- package/src/model_defaults.py +9 -10
- package/src/morning_briefing.py +281 -0
- package/src/plugins/desktop_preferences.py +63 -0
- package/src/plugins/personal_scripts.py +2 -0
- package/src/plugins/update.py +4 -0
- package/src/preference_catalog.py +438 -0
- package/src/resonance_tiers.json +4 -4
- package/src/script_registry.py +21 -0
- package/src/scripts/nexo-morning-agent.py +380 -71
- package/src/scripts/nexo-send-reply.py +49 -26
- package/src/server.py +1 -0
- package/templates/core-prompts/morning-agent-json-output.md +1 -1
- package/templates/core-prompts/morning-agent.md +5 -2
- package/tool-enforcement-map.json +40 -0
|
@@ -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
|
|
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 =
|
|
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
|
|
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(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
raise RuntimeError("Morning agent output is missing subject/body.")
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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({
|
|
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(
|
|
685
|
-
|
|
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.")
|