nexo-brain 7.29.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,7 +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
50
- from automation_preferences import format_automation_preferences_prompt_block
54
+ from automation_preferences import format_automation_preferences_prompt_block, get_automation_preferences
51
55
  from automation_controls import (
52
56
  format_operator_extra_instructions_block,
53
57
  get_operator_briefing_recipient_status,
@@ -82,6 +86,8 @@ MAX_ACTIVE_ITEMS = 8
82
86
  MAX_DIARY_ITEMS = 6
83
87
  MORNING_BRIEFING_STALE_HOURS = 12
84
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"
85
91
 
86
92
 
87
93
  def log(message: str) -> None:
@@ -447,7 +453,250 @@ def _serialize_recent_sent_emails(*, limit: int = 8) -> list[dict]:
447
453
  return result
448
454
 
449
455
 
450
- 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:
451
700
  nexo_db.init_db()
452
701
  due_followups = _serialize_followups("due", limit=MAX_DUE_ITEMS)
453
702
  due_followup_ids = {row["id"] for row in due_followups}
@@ -464,6 +713,7 @@ def collect_context(profile: dict) -> dict:
464
713
  if row["id"] not in due_reminder_ids
465
714
  ][:MAX_ACTIVE_ITEMS]
466
715
  recent_sent = _serialize_recent_sent_emails()
716
+ external = _collect_external_context(profile, preferences)
467
717
  return {
468
718
  "generated_at": datetime.now().astimezone().isoformat(),
469
719
  "today": date.today().isoformat(),
@@ -471,6 +721,9 @@ def collect_context(profile: dict) -> dict:
471
721
  "name": str(profile.get("operator_name") or "the operator"),
472
722
  "language": str(profile.get("language") or "en"),
473
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 ""),
474
727
  },
475
728
  "assistant": {
476
729
  "name": str(profile.get("assistant_name") or "Nova"),
@@ -481,6 +734,7 @@ def collect_context(profile: dict) -> dict:
481
734
  "active_followups": active_followups,
482
735
  "recent_diaries": _serialize_diaries(limit=MAX_DIARY_ITEMS),
483
736
  "recent_sent_emails_24h": recent_sent,
737
+ "external": external,
484
738
  "counts": {
485
739
  "due_reminders": len(due_reminders),
486
740
  "active_reminders": len(active_reminders),
@@ -682,7 +936,13 @@ def main(argv: list[str] | None = None) -> int:
682
936
  _set_active_claim(today, recipient)
683
937
 
684
938
  try:
685
- 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 {})
686
946
  extra_blocks = "\n".join(
687
947
  block
688
948
  for block in [
@@ -160,6 +160,34 @@ def _message_id_domain(config: dict) -> str:
160
160
  return "localhost"
161
161
 
162
162
 
163
+ def _make_smtp_ssl_context() -> ssl.SSLContext:
164
+ """Build a verified TLS context that survives macOS Python CA quirks."""
165
+ candidates: list[str] = []
166
+ try:
167
+ import certifi # type: ignore
168
+
169
+ candidates.append(certifi.where())
170
+ except Exception:
171
+ pass
172
+ candidates.extend([
173
+ os.environ.get("SSL_CERT_FILE", ""),
174
+ "/etc/ssl/cert.pem",
175
+ "/usr/local/etc/openssl/cert.pem",
176
+ "/usr/local/etc/openssl@3/cert.pem",
177
+ "/opt/homebrew/etc/openssl@3/cert.pem",
178
+ ])
179
+ for cafile in candidates:
180
+ if not cafile:
181
+ continue
182
+ try:
183
+ path = Path(cafile)
184
+ if path.is_file():
185
+ return ssl.create_default_context(cafile=str(path))
186
+ except Exception:
187
+ continue
188
+ return ssl.create_default_context()
189
+
190
+
163
191
  def classify_reply_event(body_text):
164
192
  normalized = normalize_reply_text(body_text).lower()
165
193
  if not normalized:
@@ -403,7 +431,7 @@ def send_email(config, to, cc, subject, body_text, body_html, in_reply_to, refer
403
431
  else:
404
432
  clean_recipients.append(r.strip())
405
433
 
406
- context = ssl.create_default_context()
434
+ context = _make_smtp_ssl_context()
407
435
  server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"], context=context)
408
436
  server.login(config["email"], config["password"])
409
437
  server.sendmail(config["email"], clean_recipients, msg.as_string())
package/src/server.py CHANGED
@@ -300,6 +300,7 @@ def _run_startup_preflight_sync() -> None:
300
300
  _ESSENTIAL_MCP_STARTUP_PLUGINS = (
301
301
  "cards.py",
302
302
  "doctor.py",
303
+ "desktop_preferences.py",
303
304
  "episodic_memory.py",
304
305
  "evolution.py",
305
306
  "lifecycle_events.py",
@@ -3,6 +3,46 @@
3
3
  "version": "2.2.0",
4
4
  "description": "Canonical map of all NEXO Brain MCP tools with enforcement rules, dependency chains, and behavioral hints. Source of truth for Protocol Enforcer (Desktop + headless). v2.1 adds schema support for Phase 2 event-driven rules (R01-R33): server_side_rules at tool level, per-rule mode (shadow|soft|hard), core_rule flag, and new rule types (pre_tool_intent, post_user_message, on_output_classify, before_tool_strict_block). Backward-compatible: executors that only handle v2.0 rule types ignore the new fields.",
5
5
  "tools": {
6
+ "nexo_desktop_preferences_catalog": {
7
+ "description": "List Desktop/Brain preferences the agent can explain or change",
8
+ "category": "preferences",
9
+ "source": "plugin:desktop_preferences",
10
+ "requires": [],
11
+ "provides": ["preference_catalog"],
12
+ "internal_calls": [],
13
+ "enforcement": {"level": "none", "rules": []},
14
+ "triggers_after": []
15
+ },
16
+ "nexo_desktop_preference_get": {
17
+ "description": "Read one Desktop/Brain preference by id or alias",
18
+ "category": "preferences",
19
+ "source": "plugin:desktop_preferences",
20
+ "requires": ["preference_catalog"],
21
+ "provides": ["preference_value"],
22
+ "internal_calls": [],
23
+ "enforcement": {"level": "none", "rules": []},
24
+ "triggers_after": []
25
+ },
26
+ "nexo_desktop_preference_explain": {
27
+ "description": "Explain a Desktop/Brain preference in operator-friendly terms",
28
+ "category": "preferences",
29
+ "source": "plugin:desktop_preferences",
30
+ "requires": ["preference_catalog"],
31
+ "provides": ["preference_explanation"],
32
+ "internal_calls": [],
33
+ "enforcement": {"level": "none", "rules": []},
34
+ "triggers_after": []
35
+ },
36
+ "nexo_desktop_preference_set": {
37
+ "description": "Set one supported Desktop/Brain preference or preview with dry_run",
38
+ "category": "preferences",
39
+ "source": "plugin:desktop_preferences",
40
+ "requires": ["preference_catalog"],
41
+ "provides": ["preference_value"],
42
+ "internal_calls": [],
43
+ "enforcement": {"level": "standard", "rules": []},
44
+ "triggers_after": []
45
+ },
6
46
  "nexo_adaptive_decay": {
7
47
  "description": "Trigger inter-session tension decay",
8
48
  "category": "adaptive",