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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/automation_controls.py +7 -0
- package/src/automation_preferences.py +323 -0
- package/src/cli.py +120 -0
- package/src/cli_email.py +95 -0
- package/src/db/_schema.py +18 -0
- package/src/email_presentation.py +243 -0
- package/src/morning_briefing.py +281 -0
- package/src/script_registry.py +15 -0
- package/src/scripts/nexo-morning-agent.py +118 -69
- package/src/scripts/nexo-send-reply.py +20 -25
- package/templates/core-prompts/morning-agent-json-output.md +1 -1
- package/templates/core-prompts/morning-agent.md +5 -2
|
@@ -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
|
|
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 =
|
|
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
|
|
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(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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)
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
raise RuntimeError("Morning agent output is missing subject/body.")
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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({
|
|
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(
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
body_html
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
"
|
|
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]]
|