nexo-brain 7.28.0 → 7.29.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
  sys.path.insert(0, str(_repo_src))
48
48
 
49
49
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
50
+ from automation_preferences import format_automation_preferences_prompt_block
50
51
  from automation_controls import (
51
52
  format_operator_extra_instructions_block,
52
53
  get_operator_briefing_recipient_status,
@@ -56,9 +57,16 @@ from automation_controls import (
56
57
  )
57
58
  from client_preferences import resolve_automation_backend, resolve_client_runtime_profile
58
59
  from core_prompts import render_core_prompt
60
+ from email_presentation import build_email_presentation, normalize_agent_email_payload
59
61
  from email_sent_events import format_recent_sent_email_block, recent_sent_emails
62
+ from morning_briefing import (
63
+ LATEST_MARKDOWN_FILE,
64
+ ensure_morning_briefing_runs_table as _ensure_briefing_schema,
65
+ mark_morning_briefing_sent as _persist_morning_briefing_sent,
66
+ write_latest_briefing_artifacts,
67
+ )
60
68
  import db as nexo_db
61
- from paths import data_dir, logs_dir, operations_dir
69
+ from paths import data_dir, logs_dir
62
70
  from runtime_home import export_resolved_nexo_home
63
71
 
64
72
  NEXO_HOME = export_resolved_nexo_home()
@@ -66,7 +74,7 @@ LOG_DIR = logs_dir()
66
74
  LOG_DIR.mkdir(parents=True, exist_ok=True)
67
75
  LOG_FILE = LOG_DIR / "morning-agent.log"
68
76
  STATE_FILE = data_dir() / "morning-agent-state.json"
69
- LATEST_BRIEFING_FILE = operations_dir() / "morning-briefing-latest.md"
77
+ LATEST_BRIEFING_FILE = LATEST_MARKDOWN_FILE
70
78
  CALLER = "morning_agent"
71
79
  CLI_TIMEOUT = 1500
72
80
  MAX_DUE_ITEMS = 8
@@ -105,29 +113,7 @@ def _morning_db_connection():
105
113
 
106
114
 
107
115
  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
- )
116
+ _ensure_briefing_schema(conn)
131
117
 
132
118
 
133
119
  def _row_dict(row) -> dict:
@@ -189,8 +175,14 @@ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool
189
175
  ON CONFLICT(local_date, recipient) DO UPDATE SET
190
176
  status = 'in_progress',
191
177
  subject = '',
178
+ body_text = '',
179
+ body_html = '',
180
+ artifact_json = '',
192
181
  send_output = '',
193
182
  error = '',
183
+ desktop_shown_at = NULL,
184
+ desktop_opened_at = NULL,
185
+ desktop_dismissed_at = NULL,
194
186
  started_at = excluded.started_at,
195
187
  finished_at = NULL,
196
188
  updated_at = excluded.updated_at
@@ -226,8 +218,14 @@ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool
226
218
  UPDATE morning_briefing_runs
227
219
  SET status = 'in_progress',
228
220
  subject = '',
221
+ body_text = '',
222
+ body_html = '',
223
+ artifact_json = '',
229
224
  send_output = '',
230
225
  error = '',
226
+ desktop_shown_at = NULL,
227
+ desktop_opened_at = NULL,
228
+ desktop_dismissed_at = NULL,
231
229
  started_at = ?,
232
230
  finished_at = NULL,
233
231
  updated_at = ?
@@ -268,24 +266,25 @@ def _record_existing_morning_briefing_sent(local_date: str, recipient: str, stat
268
266
  conn.commit()
269
267
 
270
268
 
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 "")),
269
+ def _mark_morning_briefing_sent(
270
+ local_date: str,
271
+ recipient: str,
272
+ *,
273
+ subject: str,
274
+ body_text: str,
275
+ body_html: str,
276
+ send_output: str,
277
+ artifact_payload: dict | None = None,
278
+ ) -> None:
279
+ _persist_morning_briefing_sent(
280
+ local_date=local_date,
281
+ recipient=recipient,
282
+ subject=subject,
283
+ body_text=body_text,
284
+ body_html=body_html,
285
+ send_output=send_output,
286
+ artifact_payload=artifact_payload,
287
287
  )
288
- conn.commit()
289
288
 
290
289
 
291
290
  def _mark_morning_briefing_failed(local_date: str, recipient: str, *, error: str) -> None:
@@ -548,7 +547,7 @@ def _extract_json_payload(raw_text: str) -> dict:
548
547
  raise RuntimeError("Morning agent returned invalid JSON output.")
549
548
 
550
549
 
551
- def generate_briefing(prompt: str) -> tuple[str, str]:
550
+ def generate_briefing(prompt: str):
552
551
  backend = resolve_automation_backend()
553
552
  profile = resolve_client_runtime_profile(backend) if backend != "none" else {"model": "", "reasoning_effort": ""}
554
553
  profile_label = profile.get("model") or "default"
@@ -576,33 +575,42 @@ def generate_briefing(prompt: str) -> tuple[str, str]:
576
575
  raise RuntimeError(detail or f"automation backend exited {result.returncode}")
577
576
 
578
577
  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"
578
+ try:
579
+ return normalize_agent_email_payload(payload)
580
+ except RuntimeError as exc:
581
+ raise RuntimeError("Morning agent output is missing subject/body.") from exc
582
+
583
+
584
+ def write_latest_briefing(
585
+ *,
586
+ recipient: str,
587
+ subject: str,
588
+ body_text: str,
589
+ body_html: str,
590
+ local_date: str = "",
591
+ run_id: int | None = None,
592
+ ) -> dict:
593
+ return write_latest_briefing_artifacts(
594
+ recipient=recipient,
595
+ subject=subject,
596
+ body_text=body_text,
597
+ body_html=body_html,
598
+ local_date=local_date,
599
+ run_id=run_id,
594
600
  )
595
- LATEST_BRIEFING_FILE.write_text(rendered, encoding="utf-8")
596
601
 
597
602
 
598
- def send_briefing(*, recipient: str, subject: str, body: str) -> str:
603
+ def send_briefing(*, recipient: str, subject: str, body_text: str, body_html: str) -> str:
599
604
  sender = get_send_reply_script_path(local_script_dir=_script_dir)
600
605
  if not sender.exists():
601
606
  raise RuntimeError(f"nexo-send-reply.py not found at {sender}")
602
607
 
603
608
  tmp_fd, tmp_path = tempfile.mkstemp(prefix="morning-briefing-", suffix=".txt")
604
609
  os.close(tmp_fd)
605
- Path(tmp_path).write_text(body, encoding="utf-8")
610
+ html_fd, html_path = tempfile.mkstemp(prefix="morning-briefing-", suffix=".html")
611
+ os.close(html_fd)
612
+ Path(tmp_path).write_text(body_text, encoding="utf-8")
613
+ Path(html_path).write_text(body_html, encoding="utf-8")
606
614
  try:
607
615
  result = subprocess.run(
608
616
  [
@@ -614,6 +622,12 @@ def send_briefing(*, recipient: str, subject: str, body: str) -> str:
614
622
  subject,
615
623
  "--body-file",
616
624
  tmp_path,
625
+ "--html-file",
626
+ html_path,
627
+ "--audience",
628
+ "operator",
629
+ "--message-kind",
630
+ "morning_briefing",
617
631
  ],
618
632
  capture_output=True,
619
633
  text=True,
@@ -621,6 +635,7 @@ def send_briefing(*, recipient: str, subject: str, body: str) -> str:
621
635
  )
622
636
  finally:
623
637
  Path(tmp_path).unlink(missing_ok=True)
638
+ Path(html_path).unlink(missing_ok=True)
624
639
 
625
640
  if result.returncode != 0:
626
641
  detail = (result.stderr or result.stdout or "").strip()
@@ -668,27 +683,61 @@ def main(argv: list[str] | None = None) -> int:
668
683
 
669
684
  try:
670
685
  context = collect_context(profile)
686
+ extra_blocks = "\n".join(
687
+ block
688
+ for block in [
689
+ format_automation_preferences_prompt_block("morning-agent"),
690
+ format_operator_extra_instructions_block("morning-agent"),
691
+ ]
692
+ if block.strip()
693
+ )
671
694
  prompt = build_prompt(
672
695
  context,
673
- extra_instructions_block=format_operator_extra_instructions_block("morning-agent"),
696
+ extra_instructions_block=extra_blocks,
697
+ )
698
+ presentation = generate_briefing(prompt)
699
+ body_text = append_recent_sent_email_block(presentation.body_text)
700
+ if body_text != presentation.body_text:
701
+ presentation = build_email_presentation(subject=presentation.subject, body_text=body_text)
702
+ artifact_payload = write_latest_briefing(
703
+ recipient=recipient or "[dry-run]",
704
+ subject=presentation.subject,
705
+ body_text=presentation.body_text,
706
+ body_html=presentation.body_html,
707
+ local_date=today,
674
708
  )
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
709
 
679
710
  if args.dry_run:
680
- print(json.dumps({"subject": subject, "body": body}, indent=2, ensure_ascii=False))
711
+ print(json.dumps({
712
+ "subject": presentation.subject,
713
+ "body": presentation.body_text,
714
+ "body_text": presentation.body_text,
715
+ "body_html": presentation.body_html,
716
+ }, indent=2, ensure_ascii=False))
681
717
  return 0
682
718
 
683
719
  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)
720
+ send_output = send_briefing(
721
+ recipient=recipient,
722
+ subject=presentation.subject,
723
+ body_text=presentation.body_text,
724
+ body_html=presentation.body_html,
725
+ )
726
+ _mark_morning_briefing_sent(
727
+ today,
728
+ recipient,
729
+ subject=presentation.subject,
730
+ body_text=presentation.body_text,
731
+ body_html=presentation.body_html,
732
+ send_output=send_output,
733
+ artifact_payload=artifact_payload,
734
+ )
686
735
  _clear_active_claim()
687
736
  save_state({
688
737
  "last_sent_date": today,
689
738
  "last_sent_at": datetime.now().astimezone().isoformat(),
690
739
  "last_recipient": recipient,
691
- "last_subject": subject,
740
+ "last_subject": presentation.subject,
692
741
  "last_send_output": send_output,
693
742
  })
694
743
  log("Morning briefing sent.")
@@ -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()
@@ -440,6 +441,8 @@ def build_parser():
440
441
  parser.add_argument("--references", default="")
441
442
  parser.add_argument("--body-file", required=True, help="Plain text body file")
442
443
  parser.add_argument("--html-file", default="", help="HTML body file (optional)")
444
+ parser.add_argument("--audience", default="", help="Message audience label for continuity metadata")
445
+ parser.add_argument("--message-kind", default="", help="Message kind label for continuity metadata")
443
446
  parser.add_argument("--quote-file", default="")
444
447
  parser.add_argument("--quote-from", default="")
445
448
  parser.add_argument("--quote-date", default="")
@@ -465,35 +468,25 @@ def main(argv=None):
465
468
  if thread:
466
469
  body_text = f"{body_text}{thread}"
467
470
 
468
- # HTML body
469
- body_html = None
471
+ # HTML body. Any agent-provided HTML is treated as untrusted and normalized
472
+ # through email_presentation before SMTP or continuity records see it.
470
473
  html_thread = build_html_thread(args.thread_file)
471
474
  if args.html_file and Path(args.html_file).exists():
472
475
  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>"""
476
+ html_fragment = html_content
481
477
  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>"""
478
+ html_fragment = text_to_html_fragment(reply_body)
479
+ html_quote = build_html_quoted(args.quote_file, args.quote_from, args.quote_date)
480
+ html_fragment = f"{html_fragment}{html_quote}{html_thread}"
481
+ presentation = build_email_presentation(
482
+ subject=args.subject,
483
+ body_text=body_text,
484
+ body_html=html_fragment,
485
+ signature=signature_from_config(config, fallback=_signature_label(config)),
486
+ include_signature=True,
487
+ )
488
+ body_text = presentation.body_text
489
+ body_html = presentation.body_html
497
490
 
498
491
  try:
499
492
  msg_id, raw_message = send_email(
@@ -531,6 +524,8 @@ def main(argv=None):
531
524
  "sent_copy_saved": sent_copy_saved,
532
525
  "lifecycle_event": lifecycle_event,
533
526
  "account_label": (args.account_label or "").strip(),
527
+ "audience": (args.audience or "").strip(),
528
+ "message_kind": (args.message_kind or "").strip(),
534
529
  },
535
530
  )
536
531
  except Exception as sent_event_exc:
@@ -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]]