nexo-brain 7.15.0 → 7.15.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.15.0",
3
+ "version": "7.15.1",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.15.0` is the current packaged-runtime line. Minor release over v7.14.0 Brain unifies sent-email continuity across send paths, moves cognitive recall to multilingual embeddings, forces tagged learnings into context, hardens email loop guards and headless runners, exposes learning creation dates, and adds AUTO-N burst postmortems.
21
+ Version `7.15.1` is the current packaged-runtime line. Patch release over v7.15.0 - Brain drains larger self-audit clusters, bounds hook history with update-time cleanup, filters normal Codex bootstrap reads, routes email-monitor effort by message complexity, and locks morning briefings by local date and recipient.
22
+
23
+ Previously in `7.15.0`: minor release — Brain unifies sent-email continuity across send paths, moves cognitive recall to multilingual embeddings, forces tagged learnings into context, hardens email loop guards and headless runners, exposes learning creation dates, and adds AUTO-N burst postmortems.
22
24
 
23
25
  Previously in `7.14.0`: minor release — Brain closes the install/reliability loop with update-path venv recovery, platform-gated wheels, WSL Desktop-managed flag preservation, startup memory authority warnings, legacy MEMORY write blocking, post-action real-world verification, and stale followup triage.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.15.0",
3
+ "version": "7.15.1",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
package/src/cli.py CHANGED
@@ -19,6 +19,7 @@ Entry points:
19
19
  nexo scripts run NAME_OR_PATH [-- args...]
20
20
  nexo scripts doctor [NAME_OR_PATH] [--json]
21
21
  nexo scripts call TOOL --input JSON [--json-output]
22
+ nexo automations reactivate NAME [--test-run] [--json]
22
23
  nexo skills list [--level ...] [--source-kind ...] [--json]
23
24
  nexo skills get ID [--json]
24
25
  nexo skills apply ID [--params JSON] [--mode ...] [--dry-run] [--json]
@@ -688,6 +689,22 @@ def _automations_set_enabled(args, enabled):
688
689
  return 0
689
690
 
690
691
 
692
+ def _automations_reactivate(args):
693
+ from script_registry import reactivate_automation
694
+
695
+ result = reactivate_automation(args.name, test_run=bool(getattr(args, "test_run", False)))
696
+ if args.json:
697
+ print(json.dumps(result, indent=2, ensure_ascii=False))
698
+ return 0 if result.get("ok") else 1
699
+ if not result.get("ok"):
700
+ print(result.get("error", "Could not reactivate automation"), file=sys.stderr)
701
+ return 1
702
+ print(f"Automation {result['name']} enabled.")
703
+ if result.get("test_run"):
704
+ print("Test run completed.")
705
+ return 0
706
+
707
+
691
708
  def _automations_status(args):
692
709
  from script_registry import get_automation_status
693
710
 
@@ -2956,6 +2973,14 @@ def main():
2956
2973
  automations_disable_p.add_argument("name", help="Automation name or path")
2957
2974
  automations_disable_p.add_argument("--json", action="store_true", help="JSON output")
2958
2975
 
2976
+ automations_reactivate_p = automations_sub.add_parser(
2977
+ "reactivate",
2978
+ help="Enable an automation and optionally run a check",
2979
+ )
2980
+ automations_reactivate_p.add_argument("name", help="Automation name or path")
2981
+ automations_reactivate_p.add_argument("--test-run", action="store_true", help="Run the automation's check without sending")
2982
+ automations_reactivate_p.add_argument("--json", action="store_true", help="JSON output")
2983
+
2959
2984
  automations_status_p = automations_sub.add_parser("status", help="Read automation status")
2960
2985
  automations_status_p.add_argument("name", help="Automation name or path")
2961
2986
  automations_status_p.add_argument("--json", action="store_true", help="JSON output")
@@ -3439,6 +3464,8 @@ def main():
3439
3464
  return _automations_set_enabled(args, True)
3440
3465
  elif args.automations_command == "disable":
3441
3466
  return _automations_set_enabled(args, False)
3467
+ elif args.automations_command == "reactivate":
3468
+ return _automations_reactivate(args)
3442
3469
  elif args.automations_command == "status":
3443
3470
  return _automations_status(args)
3444
3471
  elif args.automations_command == "instructions":
package/src/db/_schema.py CHANGED
@@ -1,4 +1,6 @@
1
1
  """NEXO DB — Schema module."""
2
+ import time
3
+
2
4
  from db._core import get_db
3
5
  from db._fts import _migrate_add_column, _migrate_add_index
4
6
 
@@ -945,6 +947,70 @@ def _m56_session_correction_requirements(conn):
945
947
  conn.execute("CREATE INDEX IF NOT EXISTS idx_session_correction_requirements_detected ON session_correction_requirements(detected_at)")
946
948
 
947
949
 
950
+ def _m57_hook_runs_retention(conn):
951
+ """Bound hook_runs so existing installs stop growing without manual cleanup."""
952
+ try:
953
+ table = conn.execute(
954
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='hook_runs'"
955
+ ).fetchone()
956
+ except Exception:
957
+ table = None
958
+ if not table:
959
+ _m39_hook_runs(conn)
960
+
961
+ retention_days = 7
962
+ max_rows = 19000
963
+ cutoff = time.time() - (retention_days * 86400)
964
+ conn.execute("DELETE FROM hook_runs WHERE started_at < ?", (cutoff,))
965
+ row = conn.execute("SELECT COUNT(*) FROM hook_runs").fetchone()
966
+ total = int((row[0] if row else 0) or 0)
967
+ if total > max_rows:
968
+ conn.execute(
969
+ """
970
+ DELETE FROM hook_runs
971
+ WHERE id NOT IN (
972
+ SELECT id
973
+ FROM hook_runs
974
+ ORDER BY started_at DESC, id DESC
975
+ LIMIT ?
976
+ )
977
+ """,
978
+ (max_rows,),
979
+ )
980
+ try:
981
+ conn.commit()
982
+ conn.execute("VACUUM")
983
+ except Exception:
984
+ pass
985
+
986
+
987
+ def _m58_morning_briefing_runs(conn):
988
+ """Atomic dedupe lock for daily morning briefings."""
989
+ conn.execute(
990
+ """CREATE TABLE IF NOT EXISTS morning_briefing_runs (
991
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
992
+ local_date TEXT NOT NULL,
993
+ recipient TEXT NOT NULL,
994
+ status TEXT NOT NULL DEFAULT 'in_progress',
995
+ subject TEXT DEFAULT '',
996
+ send_output TEXT DEFAULT '',
997
+ error TEXT DEFAULT '',
998
+ started_at TEXT DEFAULT (datetime('now')),
999
+ finished_at TEXT DEFAULT NULL,
1000
+ updated_at TEXT DEFAULT (datetime('now')),
1001
+ UNIQUE(local_date, recipient)
1002
+ )"""
1003
+ )
1004
+ conn.execute(
1005
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_date "
1006
+ "ON morning_briefing_runs(local_date)"
1007
+ )
1008
+ conn.execute(
1009
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_status "
1010
+ "ON morning_briefing_runs(status)"
1011
+ )
1012
+
1013
+
948
1014
  def _m39_hook_runs(conn):
949
1015
  """Persist hook lifecycle observability — closes Fase 3 item 7.
950
1016
 
@@ -1517,6 +1583,8 @@ MIGRATIONS = [
1517
1583
  (54, "continuity_snapshots", _m54_continuity_snapshots),
1518
1584
  (55, "cortex_critique_trace", _m55_cortex_critique_trace),
1519
1585
  (56, "session_correction_requirements", _m56_session_correction_requirements),
1586
+ (57, "hook_runs_retention", _m57_hook_runs_retention),
1587
+ (58, "morning_briefing_runs", _m58_morning_briefing_runs),
1520
1588
  ]
1521
1589
 
1522
1590
 
@@ -503,6 +503,18 @@ def _extract_declared_file_targets(args: dict, cwd: str) -> set[str]:
503
503
  return resolved
504
504
 
505
505
 
506
+ _CODEX_PRESTARTUP_READ_EXEMPT_FILENAMES = {"calibration.json", "project-atlas.json"}
507
+
508
+
509
+ def _is_codex_prestartup_runtime_read_exempt(touched: str, operation: str, startup_seen: bool) -> bool:
510
+ if startup_seen or operation != "read":
511
+ return False
512
+ normalized = _normalize_path_token(touched)
513
+ if Path(normalized).name not in _CODEX_PRESTARTUP_READ_EXEMPT_FILENAMES:
514
+ return False
515
+ return "/.nexo/" in normalized
516
+
517
+
506
518
  def _load_active_conditioned_learnings() -> list[dict]:
507
519
  db_path = paths.db_path()
508
520
  if not db_path.is_file():
@@ -575,6 +587,7 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
575
587
  protocol_files: set[str] = set()
576
588
  guard_files: set[str] = set()
577
589
  guard_ack = False
590
+ startup_seen = False
578
591
  session_touches = 0
579
592
  session_samples: list[dict] = []
580
593
 
@@ -601,6 +614,9 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
601
614
  name = str(payload.get("name", "") or "")
602
615
  args = _parse_jsonish_arguments(payload.get("arguments"))
603
616
 
617
+ if name in {"mcp__nexo__nexo_startup", "nexo_startup"}:
618
+ startup_seen = True
619
+ continue
604
620
  if name in {"mcp__nexo__nexo_task_open", "nexo_task_open"}:
605
621
  protocol_active = True
606
622
  protocol_files.update(_extract_declared_file_targets(args, cwd))
@@ -628,6 +644,8 @@ def _recent_codex_conditioned_file_discipline_status(*, days: int = 7, max_files
628
644
  continue
629
645
 
630
646
  for touched, operation in touched_files:
647
+ if _is_codex_prestartup_runtime_read_exempt(touched, operation, startup_seen):
648
+ continue
631
649
  matches = [row for row in conditioned if _applies_to_matches_file(str(row.get("applies_to", "")), touched)]
632
650
  if not matches:
633
651
  continue
@@ -33,6 +33,8 @@ from db import get_db
33
33
 
34
34
 
35
35
  _VALID_STATUS = {"ok", "error", "skipped", "timeout", "blocked"}
36
+ HOOK_RUNS_RETENTION_DAYS = 7
37
+ HOOK_RUNS_MAX_ROWS = 19000
36
38
 
37
39
 
38
40
  def _coerce_status(exit_code: int, status: str = "") -> str:
@@ -45,6 +47,71 @@ def _coerce_status(exit_code: int, status: str = "") -> str:
45
47
  return "error"
46
48
 
47
49
 
50
+ def cleanup_hook_runs(
51
+ *,
52
+ retention_days: int = HOOK_RUNS_RETENTION_DAYS,
53
+ max_rows: int = HOOK_RUNS_MAX_ROWS,
54
+ now: float | None = None,
55
+ vacuum: bool = False,
56
+ conn=None,
57
+ ) -> dict:
58
+ """Keep hook_runs bounded by age and newest-row count.
59
+
60
+ This is deliberately best-effort: hook observability must never make
61
+ the hook path fail. ``started_at`` is stored as REAL epoch seconds, so
62
+ the comparison is numeric and works across SQLite versions.
63
+ """
64
+ try:
65
+ days = max(1, int(retention_days))
66
+ except (TypeError, ValueError):
67
+ days = HOOK_RUNS_RETENTION_DAYS
68
+ try:
69
+ limit = max(1, int(max_rows))
70
+ except (TypeError, ValueError):
71
+ limit = HOOK_RUNS_MAX_ROWS
72
+ now_epoch = float(time.time() if now is None else now)
73
+ cutoff = now_epoch - (days * 86400)
74
+ try:
75
+ db_conn = conn or get_db()
76
+ old_cur = db_conn.execute("DELETE FROM hook_runs WHERE started_at < ?", (cutoff,))
77
+ deleted_old = int(old_cur.rowcount or 0)
78
+ count_row = db_conn.execute("SELECT COUNT(*) FROM hook_runs").fetchone()
79
+ remaining = int((count_row[0] if count_row else 0) or 0)
80
+ deleted_overflow = 0
81
+ if remaining > limit:
82
+ overflow_cur = db_conn.execute(
83
+ """
84
+ DELETE FROM hook_runs
85
+ WHERE id NOT IN (
86
+ SELECT id
87
+ FROM hook_runs
88
+ ORDER BY started_at DESC, id DESC
89
+ LIMIT ?
90
+ )
91
+ """,
92
+ (limit,),
93
+ )
94
+ deleted_overflow = int(overflow_cur.rowcount or 0)
95
+ remaining = limit
96
+ try:
97
+ db_conn.commit()
98
+ except Exception:
99
+ pass
100
+ if vacuum and (deleted_old or deleted_overflow):
101
+ try:
102
+ db_conn.execute("VACUUM")
103
+ except Exception:
104
+ pass
105
+ return {
106
+ "ok": True,
107
+ "deleted_old": deleted_old,
108
+ "deleted_overflow": deleted_overflow,
109
+ "remaining": remaining,
110
+ }
111
+ except Exception as exc:
112
+ return {"ok": False, "error": str(exc), "deleted_old": 0, "deleted_overflow": 0, "remaining": 0}
113
+
114
+
48
115
  def record_hook_run(
49
116
  hook_name: str,
50
117
  *,
@@ -98,6 +165,10 @@ def record_hook_run(
98
165
  now_epoch = time.time()
99
166
  try:
100
167
  conn = get_db()
168
+ try:
169
+ cleanup_hook_runs(conn=conn)
170
+ except Exception:
171
+ pass
101
172
  cur = conn.execute(
102
173
  "INSERT INTO hook_runs (hook_name, started_at, duration_ms, exit_code, "
103
174
  "status, session_id, summary, metadata, created_at) "
@@ -14,6 +14,7 @@ import re
14
14
  import shutil
15
15
  import stat
16
16
  import subprocess
17
+ import sys
17
18
  import time
18
19
  from pathlib import Path
19
20
  import paths
@@ -2361,6 +2362,73 @@ def get_automation_status(name_or_path: str) -> dict:
2361
2362
  return get_personal_script_status(name_or_path)
2362
2363
 
2363
2364
 
2365
+ def _script_execution_command(script: dict) -> list[str]:
2366
+ path = str(script.get("path") or "").strip()
2367
+ runtime = str(script.get("runtime") or "").strip().lower()
2368
+ if runtime == "python" or path.endswith(".py"):
2369
+ return [sys.executable, path]
2370
+ if runtime == "shell" or path.endswith((".sh", ".bash", ".zsh")):
2371
+ return ["/bin/bash", path]
2372
+ return [path]
2373
+
2374
+
2375
+ def reactivate_automation(name_or_path: str, *, test_run: bool = False, timeout_seconds: int = 180) -> dict:
2376
+ """Enable an operator automation and optionally run its built-in check."""
2377
+ enable_result = set_automation_enabled(name_or_path, True)
2378
+ if not enable_result.get("ok"):
2379
+ return enable_result
2380
+
2381
+ status_result = get_automation_status(name_or_path)
2382
+ result = {
2383
+ "ok": bool(status_result.get("ok", True)),
2384
+ "name": enable_result.get("name") or name_or_path,
2385
+ "enabled": True,
2386
+ "changed": bool(enable_result.get("changed")),
2387
+ "status": status_result,
2388
+ }
2389
+ if not test_run:
2390
+ return result
2391
+
2392
+ resolved = resolve_script_reference(str(result["name"])) or resolve_script_reference(name_or_path)
2393
+ if not resolved:
2394
+ result.update({"ok": False, "error": "Automation enabled, but the test run could not be started."})
2395
+ return result
2396
+ if str(resolved.get("name") or "").strip() != "morning-agent":
2397
+ result.update({"ok": False, "error": "Test run is available for morning-agent only."})
2398
+ return result
2399
+
2400
+ command = _script_execution_command(resolved) + ["--dry-run"]
2401
+ env = os.environ.copy()
2402
+ env["NEXO_HEADLESS"] = "1"
2403
+ try:
2404
+ completed = subprocess.run(
2405
+ command,
2406
+ capture_output=True,
2407
+ text=True,
2408
+ timeout=max(30, int(timeout_seconds)),
2409
+ env=env,
2410
+ )
2411
+ except Exception as exc:
2412
+ result.update({
2413
+ "ok": False,
2414
+ "error": "Test run could not start.",
2415
+ "test_run": {"ok": False, "error": str(exc)},
2416
+ })
2417
+ return result
2418
+
2419
+ test_payload = {
2420
+ "ok": completed.returncode == 0,
2421
+ "exit_code": completed.returncode,
2422
+ "stdout_tail": (completed.stdout or "")[-2000:],
2423
+ "stderr_tail": (completed.stderr or "")[-2000:],
2424
+ }
2425
+ result["test_run"] = test_payload
2426
+ if completed.returncode != 0:
2427
+ result["ok"] = False
2428
+ result["error"] = "Test run did not complete."
2429
+ return result
2430
+
2431
+
2364
2432
  def set_script_extra_instructions(name_or_path: str, instructions: str) -> dict:
2365
2433
  """Persist operator-side prompt additions without touching the core prompt."""
2366
2434
  from automation_controls import supports_operator_extra_instructions
@@ -88,6 +88,7 @@ AUDIT_HISTORY_DIR = LOG_DIR / "self-audit"
88
88
  AUDIT_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
89
89
  LOG_FILE = LOG_DIR / "self-audit.log"
90
90
  NEXO_DB = data_dir() / "nexo.db"
91
+ SELF_AUDIT_INLINE_BATCH_LIMIT = 50
91
92
  # Configure your main project repo to check for uncommitted changes (optional)
92
93
  PROJECT_REPO_DIR = None # e.g., Path.home() / "projects" / "my-repo"
93
94
  HASH_REGISTRY = core_scripts_dir() / ".watchdog-hashes"
@@ -1228,7 +1229,7 @@ def check_error_memory_loop():
1228
1229
  if repeated:
1229
1230
  resolved = 0
1230
1231
  completed_followups = 0
1231
- for signature, items in list(repeated.items())[:5]:
1232
+ for signature, items in list(repeated.items())[:SELF_AUDIT_INLINE_BATCH_LIMIT]:
1232
1233
  description = (
1233
1234
  f"Mine a canonical prevention learning from repeated failed/blocked protocol tasks around {signature}"
1234
1235
  )
@@ -1433,7 +1434,7 @@ def check_unformalized_mentions():
1433
1434
  if loose_topics:
1434
1435
  resolved = 0
1435
1436
  completed_followups = 0
1436
- for (area, signature), items in list(loose_topics.items())[:5]:
1437
+ for (area, signature), items in list(loose_topics.items())[:SELF_AUDIT_INLINE_BATCH_LIMIT]:
1437
1438
  sample_goal = str(items[0]["goal"] or "").strip()[:120]
1438
1439
  description = (
1439
1440
  f"Formalize repeated unresolved theme in {area}: '{sample_goal}' "
@@ -1523,7 +1524,7 @@ def check_automation_opportunities():
1523
1524
  }
1524
1525
  if repeated:
1525
1526
  finding("INFO", "opportunities", f"{len(repeated)} repeated manual pattern(s) are good candidates for skills/scripts")
1526
- for (area, signature), items in list(repeated.items())[:5]:
1527
+ for (area, signature), items in list(repeated.items())[:SELF_AUDIT_INLINE_BATCH_LIMIT]:
1527
1528
  sample_goal = str(items[0]["goal"] or "").strip()[:120]
1528
1529
  description = (
1529
1530
  f"Extract a reusable automation for repeated {area} work around '{sample_goal}' "
@@ -2102,6 +2102,48 @@ def build_processing_prompt(
2102
2102
  )
2103
2103
 
2104
2104
 
2105
+ _EMAIL_MONITOR_COMPLEXITY_HIGH_TERMS = (
2106
+ "urgent", "urgente", "complaint", "queja", "reclamacion", "reclamación",
2107
+ "legal", "contract", "contrato", "invoice", "factura", "payment", "pago",
2108
+ "refund", "devolucion", "devolución", "booking", "reserva", "reservation",
2109
+ "cancel", "cancelacion", "cancelación", "cancellation", "deadline",
2110
+ "plazo", "claim", "dispute", "incidencia", "broken", "error",
2111
+ )
2112
+ _EMAIL_MONITOR_COMPLEXITY_SIMPLE_TERMS = (
2113
+ "thanks", "thank you", "gracias", "ok", "okay", "received", "recibido",
2114
+ "confirmado", "confirmation", "confirmacion", "confirmación", "perfecto",
2115
+ "noted", "anotado",
2116
+ )
2117
+
2118
+
2119
+ def _email_monitor_complexity_tier(target_emails=None, *, needs_interactive=None, debt_block: str = "") -> str:
2120
+ emails = list(target_emails or [])
2121
+ interactive = list(needs_interactive or [])
2122
+ if str(debt_block or "").strip() or interactive:
2123
+ return "alto"
2124
+ if len(emails) >= 3:
2125
+ return "alto"
2126
+
2127
+ text_parts: list[str] = []
2128
+ attempts = 0
2129
+ for em in emails:
2130
+ getter = em.get if hasattr(em, "get") else lambda key, default=None: default
2131
+ attempts = max(attempts, _safe_int(getter("attempts", 0), 0))
2132
+ for key in ("subject", "from_addr", "snippet", "body", "body_text"):
2133
+ value = str(getter(key, "") or "").strip()
2134
+ if value:
2135
+ text_parts.append(value)
2136
+ if attempts > 0:
2137
+ return "alto"
2138
+
2139
+ joined = " ".join(text_parts).lower()
2140
+ if any(term in joined for term in _EMAIL_MONITOR_COMPLEXITY_HIGH_TERMS):
2141
+ return "alto"
2142
+ if emails and len(emails) <= 1 and any(term in joined for term in _EMAIL_MONITOR_COMPLEXITY_SIMPLE_TERMS):
2143
+ return "bajo"
2144
+ return "medio"
2145
+
2146
+
2105
2147
  def launch_nexo(config, debt_block="", target_emails=None):
2106
2148
  """Launch NEXO through the configured automation backend to process emails.
2107
2149
  target_emails: optional list of dicts with message_id, subject, attempts."""
@@ -2137,6 +2179,11 @@ def launch_nexo(config, debt_block="", target_emails=None):
2137
2179
  f"Resuming from checkpoint(s) for {len(target_message_ids)} email(s); "
2138
2180
  "previous attempt context attached to prompt."
2139
2181
  )
2182
+ email_tier = _email_monitor_complexity_tier(
2183
+ target_emails=target_emails,
2184
+ needs_interactive=needs_interactive,
2185
+ debt_block=debt_block,
2186
+ )
2140
2187
  prompt = build_processing_prompt(
2141
2188
  config=config,
2142
2189
  operator_name=operator_name,
@@ -2175,7 +2222,7 @@ def launch_nexo(config, debt_block="", target_emails=None):
2175
2222
  try:
2176
2223
  from resonance_map import resolve_model_and_effort
2177
2224
 
2178
- mapped_model, mapped_effort = resolve_model_and_effort("email_monitor", backend)
2225
+ mapped_model, mapped_effort = resolve_model_and_effort("email_monitor", backend, explicit_tier=email_tier)
2179
2226
  profile_label = mapped_model or "default"
2180
2227
  if mapped_effort:
2181
2228
  profile_label = f"{profile_label}/{mapped_effort}"
@@ -2217,6 +2264,7 @@ def launch_nexo(config, debt_block="", target_emails=None):
2217
2264
  timeout=effective_timeout,
2218
2265
  output_format="text",
2219
2266
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
2267
+ tier=email_tier,
2220
2268
  )
2221
2269
 
2222
2270
  if result.stdout.strip():
@@ -71,6 +71,7 @@ CLI_TIMEOUT = 1800
71
71
  MAX_DUE_ITEMS = 8
72
72
  MAX_ACTIVE_ITEMS = 8
73
73
  MAX_DIARY_ITEMS = 6
74
+ MORNING_BRIEFING_STALE_HOURS = 12
74
75
 
75
76
 
76
77
  def log(message: str) -> None:
@@ -96,6 +97,184 @@ def save_state(state: dict) -> None:
96
97
  STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n")
97
98
 
98
99
 
100
+ def _morning_db_connection():
101
+ nexo_db.init_db()
102
+ return nexo_db.get_db()
103
+
104
+
105
+ def _ensure_morning_briefing_runs_table(conn) -> None:
106
+ conn.execute(
107
+ """CREATE TABLE IF NOT EXISTS morning_briefing_runs (
108
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
109
+ local_date TEXT NOT NULL,
110
+ recipient TEXT NOT NULL,
111
+ status TEXT NOT NULL DEFAULT 'in_progress',
112
+ subject TEXT DEFAULT '',
113
+ send_output TEXT DEFAULT '',
114
+ error TEXT DEFAULT '',
115
+ started_at TEXT DEFAULT (datetime('now')),
116
+ finished_at TEXT DEFAULT NULL,
117
+ updated_at TEXT DEFAULT (datetime('now')),
118
+ UNIQUE(local_date, recipient)
119
+ )"""
120
+ )
121
+ conn.execute(
122
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_date "
123
+ "ON morning_briefing_runs(local_date)"
124
+ )
125
+ conn.execute(
126
+ "CREATE INDEX IF NOT EXISTS idx_morning_briefing_runs_status "
127
+ "ON morning_briefing_runs(status)"
128
+ )
129
+
130
+
131
+ def _row_dict(row) -> dict:
132
+ if row is None:
133
+ return {}
134
+ try:
135
+ return dict(row)
136
+ except Exception:
137
+ return {}
138
+
139
+
140
+ def _briefing_run_is_stale(row: dict) -> bool:
141
+ started_raw = str(row.get("started_at") or "").strip()
142
+ if not started_raw:
143
+ return True
144
+ try:
145
+ started = datetime.fromisoformat(started_raw.replace("Z", "+00:00"))
146
+ now = datetime.now(started.tzinfo) if started.tzinfo else datetime.now()
147
+ return (now - started).total_seconds() > (MORNING_BRIEFING_STALE_HOURS * 3600)
148
+ except Exception:
149
+ return True
150
+
151
+
152
+ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool = False) -> dict:
153
+ clean_date = str(local_date or "").strip()
154
+ clean_recipient = str(recipient or "").strip()
155
+ if not clean_date or not clean_recipient:
156
+ return {"ok": False, "acquired": False, "reason": "missing recipient"}
157
+ now = datetime.now().astimezone().isoformat()
158
+ conn = _morning_db_connection()
159
+ _ensure_morning_briefing_runs_table(conn)
160
+ if force:
161
+ conn.execute(
162
+ """
163
+ INSERT INTO morning_briefing_runs
164
+ (local_date, recipient, status, subject, send_output, error, started_at, finished_at, updated_at)
165
+ VALUES (?, ?, 'in_progress', '', '', '', ?, NULL, ?)
166
+ ON CONFLICT(local_date, recipient) DO UPDATE SET
167
+ status = 'in_progress',
168
+ subject = '',
169
+ send_output = '',
170
+ error = '',
171
+ started_at = excluded.started_at,
172
+ finished_at = NULL,
173
+ updated_at = excluded.updated_at
174
+ """,
175
+ (clean_date, clean_recipient, now, now),
176
+ )
177
+ conn.commit()
178
+ return {"ok": True, "acquired": True, "reason": "force"}
179
+
180
+ cur = conn.execute(
181
+ """
182
+ INSERT OR IGNORE INTO morning_briefing_runs
183
+ (local_date, recipient, status, started_at, updated_at)
184
+ VALUES (?, ?, 'in_progress', ?, ?)
185
+ """,
186
+ (clean_date, clean_recipient, now, now),
187
+ )
188
+ conn.commit()
189
+ if int(cur.rowcount or 0) == 1:
190
+ return {"ok": True, "acquired": True, "reason": "new"}
191
+
192
+ row = _row_dict(conn.execute(
193
+ "SELECT * FROM morning_briefing_runs WHERE local_date = ? AND recipient = ?",
194
+ (clean_date, clean_recipient),
195
+ ).fetchone())
196
+ status = str(row.get("status") or "").strip().lower()
197
+ if status == "failed" or (status == "in_progress" and _briefing_run_is_stale(row)):
198
+ conn.execute(
199
+ """
200
+ UPDATE morning_briefing_runs
201
+ SET status = 'in_progress',
202
+ subject = '',
203
+ send_output = '',
204
+ error = '',
205
+ started_at = ?,
206
+ finished_at = NULL,
207
+ updated_at = ?
208
+ WHERE local_date = ? AND recipient = ?
209
+ """,
210
+ (now, now, clean_date, clean_recipient),
211
+ )
212
+ conn.commit()
213
+ return {"ok": True, "acquired": True, "reason": "retry"}
214
+ return {"ok": True, "acquired": False, "reason": status or "already claimed", "run": row}
215
+
216
+
217
+ def _record_existing_morning_briefing_sent(local_date: str, recipient: str, state: dict) -> None:
218
+ now = datetime.now().astimezone().isoformat()
219
+ conn = _morning_db_connection()
220
+ _ensure_morning_briefing_runs_table(conn)
221
+ conn.execute(
222
+ """
223
+ INSERT OR IGNORE INTO morning_briefing_runs
224
+ (local_date, recipient, status, subject, send_output, error, started_at, finished_at, updated_at)
225
+ VALUES (?, ?, 'sent', ?, ?, '', ?, ?, ?)
226
+ """,
227
+ (
228
+ str(local_date or "").strip(),
229
+ str(recipient or "").strip(),
230
+ str(state.get("last_subject") or ""),
231
+ str(state.get("last_send_output") or ""),
232
+ str(state.get("last_sent_at") or now),
233
+ str(state.get("last_sent_at") or now),
234
+ now,
235
+ ),
236
+ )
237
+ conn.commit()
238
+
239
+
240
+ def _mark_morning_briefing_sent(local_date: str, recipient: str, *, subject: str, send_output: str) -> None:
241
+ now = datetime.now().astimezone().isoformat()
242
+ conn = _morning_db_connection()
243
+ _ensure_morning_briefing_runs_table(conn)
244
+ conn.execute(
245
+ """
246
+ UPDATE morning_briefing_runs
247
+ SET status = 'sent',
248
+ subject = ?,
249
+ send_output = ?,
250
+ error = '',
251
+ finished_at = ?,
252
+ updated_at = ?
253
+ WHERE local_date = ? AND recipient = ?
254
+ """,
255
+ (str(subject or ""), str(send_output or ""), now, now, str(local_date or ""), str(recipient or "")),
256
+ )
257
+ conn.commit()
258
+
259
+
260
+ def _mark_morning_briefing_failed(local_date: str, recipient: str, *, error: str) -> None:
261
+ now = datetime.now().astimezone().isoformat()
262
+ conn = _morning_db_connection()
263
+ _ensure_morning_briefing_runs_table(conn)
264
+ conn.execute(
265
+ """
266
+ UPDATE morning_briefing_runs
267
+ SET status = 'failed',
268
+ error = ?,
269
+ finished_at = ?,
270
+ updated_at = ?
271
+ WHERE local_date = ? AND recipient = ?
272
+ """,
273
+ (str(error or "")[:1000], now, now, str(local_date or ""), str(recipient or "")),
274
+ )
275
+ conn.commit()
276
+
277
+
99
278
  def resolve_recipient(profile: dict | None = None, *, explicit_to: str = "") -> str:
100
279
  override = str(explicit_to or "").strip()
101
280
  if override:
@@ -411,8 +590,15 @@ def main(argv: list[str] | None = None) -> int:
411
590
  today = date.today().isoformat()
412
591
  if not args.force and not args.dry_run:
413
592
  if state.get("last_sent_date") == today and state.get("last_recipient") == recipient:
593
+ _record_existing_morning_briefing_sent(today, recipient, state)
414
594
  log(f"Morning briefing already sent today to {recipient}; use --force to resend.")
415
595
  return 0
596
+ claim = _claim_morning_briefing_send(today, recipient)
597
+ if not claim.get("acquired"):
598
+ log(f"Morning briefing already handled today for {recipient}.")
599
+ return 0
600
+ elif args.force and not args.dry_run:
601
+ _claim_morning_briefing_send(today, recipient, force=True)
416
602
 
417
603
  try:
418
604
  context = collect_context(profile)
@@ -430,6 +616,7 @@ def main(argv: list[str] | None = None) -> int:
430
616
 
431
617
  log(f"Sending morning briefing to {recipient}...")
432
618
  send_output = send_briefing(recipient=recipient, subject=subject, body=body)
619
+ _mark_morning_briefing_sent(today, recipient, subject=subject, send_output=send_output)
433
620
  save_state({
434
621
  "last_sent_date": today,
435
622
  "last_sent_at": datetime.now().astimezone().isoformat(),
@@ -440,9 +627,13 @@ def main(argv: list[str] | None = None) -> int:
440
627
  log("Morning briefing sent.")
441
628
  return 0
442
629
  except AutomationBackendUnavailableError as exc:
630
+ if not args.dry_run and recipient:
631
+ _mark_morning_briefing_failed(today, recipient, error=str(exc))
443
632
  log(f"Automation backend unavailable: {exc}")
444
633
  return 1
445
634
  except Exception as exc:
635
+ if not args.dry_run and recipient:
636
+ _mark_morning_briefing_failed(today, recipient, error=str(exc))
446
637
  log(f"Morning agent failed: {exc}")
447
638
  return 1
448
639