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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/cli.py +27 -0
- package/src/db/_schema.py +68 -0
- package/src/doctor/providers/runtime.py +18 -0
- package/src/hook_observability.py +71 -0
- package/src/script_registry.py +68 -0
- package/src/scripts/nexo-daily-self-audit.py +4 -3
- package/src/scripts/nexo-email-monitor.py +49 -1
- package/src/scripts/nexo-morning-agent.py +191 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.15.
|
|
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.
|
|
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.
|
|
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) "
|
package/src/script_registry.py
CHANGED
|
@@ -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())[:
|
|
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())[:
|
|
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())[:
|
|
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
|
|