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.
@@ -47,6 +47,7 @@ if str(_repo_src) not in sys.path:
47
47
  from paths import nexo_email_dir
48
48
  from runtime_home import export_resolved_nexo_home
49
49
  from email_sent_events import record_sent_email
50
+ from email_presentation import build_email_presentation, signature_from_config, text_to_html_fragment
50
51
 
51
52
  NEXO_HOME = export_resolved_nexo_home()
52
53
  EMAIL_BASE_DIR = nexo_email_dir()
@@ -159,6 +160,34 @@ def _message_id_domain(config: dict) -> str:
159
160
  return "localhost"
160
161
 
161
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
+
162
191
  def classify_reply_event(body_text):
163
192
  normalized = normalize_reply_text(body_text).lower()
164
193
  if not normalized:
@@ -402,7 +431,7 @@ def send_email(config, to, cc, subject, body_text, body_html, in_reply_to, refer
402
431
  else:
403
432
  clean_recipients.append(r.strip())
404
433
 
405
- context = ssl.create_default_context()
434
+ context = _make_smtp_ssl_context()
406
435
  server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"], context=context)
407
436
  server.login(config["email"], config["password"])
408
437
  server.sendmail(config["email"], clean_recipients, msg.as_string())
@@ -440,6 +469,8 @@ def build_parser():
440
469
  parser.add_argument("--references", default="")
441
470
  parser.add_argument("--body-file", required=True, help="Plain text body file")
442
471
  parser.add_argument("--html-file", default="", help="HTML body file (optional)")
472
+ parser.add_argument("--audience", default="", help="Message audience label for continuity metadata")
473
+ parser.add_argument("--message-kind", default="", help="Message kind label for continuity metadata")
443
474
  parser.add_argument("--quote-file", default="")
444
475
  parser.add_argument("--quote-from", default="")
445
476
  parser.add_argument("--quote-date", default="")
@@ -465,35 +496,25 @@ def main(argv=None):
465
496
  if thread:
466
497
  body_text = f"{body_text}{thread}"
467
498
 
468
- # HTML body
469
- body_html = None
499
+ # HTML body. Any agent-provided HTML is treated as untrusted and normalized
500
+ # through email_presentation before SMTP or continuity records see it.
470
501
  html_thread = build_html_thread(args.thread_file)
471
502
  if args.html_file and Path(args.html_file).exists():
472
503
  html_content = Path(args.html_file).read_text(encoding="utf-8").strip()
473
- html_quote = build_html_quoted(args.quote_file, args.quote_from, args.quote_date)
474
- body_html = f"""<!DOCTYPE html>
475
- <html><head><meta charset="utf-8"></head>
476
- <body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;color:#333;line-height:1.6;">
477
- {html_content}
478
- {html_quote}
479
- {html_thread}
480
- </body></html>"""
504
+ html_fragment = html_content
481
505
  else:
482
- # Auto-generate HTML from plain text
483
- escaped_body = html.escape(Path(args.body_file).read_text(encoding="utf-8").strip())
484
- paragraphs = escaped_body.split("\n\n")
485
- html_body = "".join(f"<p>{p.replace(chr(10), '<br>')}</p>" for p in paragraphs)
486
- html_quote = build_html_quoted(args.quote_file, args.quote_from, args.quote_date)
487
- signature = html.escape(_signature_label(config))
488
- body_html = f"""<!DOCTYPE html>
489
- <html><head><meta charset="utf-8"></head>
490
- <body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:14px;color:#333;line-height:1.6;">
491
- {html_body}
492
- {html_quote}
493
- {html_thread}
494
- <hr style="border:none;border-top:1px solid #ddd;margin:20px 0;">
495
- <p style="color:#888;font-size:11px;">{signature}</p>
496
- </body></html>"""
506
+ html_fragment = text_to_html_fragment(reply_body)
507
+ html_quote = build_html_quoted(args.quote_file, args.quote_from, args.quote_date)
508
+ html_fragment = f"{html_fragment}{html_quote}{html_thread}"
509
+ presentation = build_email_presentation(
510
+ subject=args.subject,
511
+ body_text=body_text,
512
+ body_html=html_fragment,
513
+ signature=signature_from_config(config, fallback=_signature_label(config)),
514
+ include_signature=True,
515
+ )
516
+ body_text = presentation.body_text
517
+ body_html = presentation.body_html
497
518
 
498
519
  try:
499
520
  msg_id, raw_message = send_email(
@@ -531,6 +552,8 @@ def main(argv=None):
531
552
  "sent_copy_saved": sent_copy_saved,
532
553
  "lifecycle_event": lifecycle_event,
533
554
  "account_label": (args.account_label or "").strip(),
555
+ "audience": (args.audience or "").strip(),
556
+ "message_kind": (args.message_kind or "").strip(),
534
557
  },
535
558
  )
536
559
  except Exception as sent_event_exc:
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",
@@ -1 +1 @@
1
- Return raw JSON only. No markdown fences. No commentary. No tool calls unless absolutely unavoidable.
1
+ Return raw JSON only. Include subject and body_text. You may include body_html with simple safe email HTML. No markdown fences. No commentary. No tool calls unless absolutely unavoidable.
@@ -11,13 +11,16 @@ Hard rules:
11
11
  - Prioritise what changed recently, what is due now, what is blocked, and what deserves focus today.
12
12
  - If activity was quiet, say so plainly instead of padding.
13
13
  - Mention operator decisions only when the context actually supports them.
14
- - Keep the email concise: roughly 180-350 words.
14
+ - Keep the email concise unless structured preferences ask for more detail: roughly 180-350 words.
15
15
  - Use short sections and bullets when useful.
16
16
  [[extra_section]]Return ONLY a valid JSON object with this exact shape:
17
17
  {
18
18
  "subject": "string",
19
- "body": "string"
19
+ "body_text": "plain text email body",
20
+ "body_html": "optional simple HTML body using p, ul, ol, li, strong, em, h2, h3, blockquote, table"
20
21
  }
21
22
 
23
+ Compatibility rule: if you cannot produce body_html, return body_text only. Older "body" is accepted but body_text is preferred.
24
+
22
25
  Structured context:
23
26
  [[context_json]]
@@ -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",