nexo-brain 2.4.0 → 2.5.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.
Files changed (80) hide show
  1. package/README.md +65 -2
  2. package/bin/nexo-brain.js +208 -11
  3. package/bin/nexo.js +55 -0
  4. package/community/skills/.gitkeep +1 -0
  5. package/package.json +5 -2
  6. package/src/auto_update.py +158 -8
  7. package/src/cli.py +605 -0
  8. package/src/cognitive/_ingest.py +1 -1
  9. package/src/cognitive/_memory.py +4 -4
  10. package/src/crons/manifest.json +8 -0
  11. package/src/dashboard/app.py +700 -35
  12. package/src/dashboard/templates/adaptive.html +112 -218
  13. package/src/dashboard/templates/artifacts.html +133 -0
  14. package/src/dashboard/templates/backups.html +136 -0
  15. package/src/dashboard/templates/base.html +413 -0
  16. package/src/dashboard/templates/calendar.html +523 -654
  17. package/src/dashboard/templates/chat.html +356 -0
  18. package/src/dashboard/templates/claims.html +259 -0
  19. package/src/dashboard/templates/cortex.html +262 -0
  20. package/src/dashboard/templates/credentials.html +128 -0
  21. package/src/dashboard/templates/crons.html +370 -0
  22. package/src/dashboard/templates/dashboard.html +383 -578
  23. package/src/dashboard/templates/dreams.html +252 -0
  24. package/src/dashboard/templates/email.html +160 -0
  25. package/src/dashboard/templates/evolution.html +189 -0
  26. package/src/dashboard/templates/feed.html +249 -0
  27. package/src/dashboard/templates/followup_health.html +170 -0
  28. package/src/dashboard/templates/graph.html +191 -269
  29. package/src/dashboard/templates/guard.html +259 -0
  30. package/src/dashboard/templates/inbox.html +220 -346
  31. package/src/dashboard/templates/memory.html +317 -197
  32. package/src/dashboard/templates/operations.html +521 -698
  33. package/src/dashboard/templates/plugins.html +185 -0
  34. package/src/dashboard/templates/rules.html +246 -0
  35. package/src/dashboard/templates/sentiment.html +247 -0
  36. package/src/dashboard/templates/sessions.html +215 -182
  37. package/src/dashboard/templates/skills.html +329 -0
  38. package/src/dashboard/templates/somatic.html +68 -172
  39. package/src/dashboard/templates/triggers.html +133 -0
  40. package/src/dashboard/templates/trust.html +360 -0
  41. package/src/db/__init__.py +5 -0
  42. package/src/db/_schema.py +16 -1
  43. package/src/db/_sessions.py +22 -0
  44. package/src/db/_skills.py +980 -274
  45. package/src/doctor/__init__.py +1 -0
  46. package/src/doctor/formatters.py +52 -0
  47. package/src/doctor/models.py +44 -0
  48. package/src/doctor/orchestrator.py +42 -0
  49. package/src/doctor/providers/__init__.py +1 -0
  50. package/src/doctor/providers/boot.py +206 -0
  51. package/src/doctor/providers/deep.py +292 -0
  52. package/src/doctor/providers/runtime.py +686 -0
  53. package/src/hooks/post-compact.sh +5 -1
  54. package/src/hooks/pre-compact.sh +1 -1
  55. package/src/plugins/doctor.py +36 -0
  56. package/src/plugins/evolution.py +2 -1
  57. package/src/plugins/skills.py +135 -175
  58. package/src/requirements.txt +1 -0
  59. package/src/script_registry.py +322 -0
  60. package/src/scripts/deep-sleep/apply_findings.py +63 -48
  61. package/src/scripts/deep-sleep/extract-prompt.md +14 -0
  62. package/src/scripts/deep-sleep/synthesize-prompt.md +36 -0
  63. package/src/scripts/deep-sleep/synthesize.py +37 -1
  64. package/src/scripts/nexo-dashboard.sh +29 -0
  65. package/src/scripts/nexo-day-orchestrator.sh +139 -0
  66. package/src/scripts/nexo-evolution-run.py +2 -1
  67. package/src/scripts/nexo-learning-housekeep.py +1 -1
  68. package/src/scripts/nexo-watchdog.sh +1 -1
  69. package/src/server.py +9 -5
  70. package/src/skills/run-runtime-doctor/guide.md +12 -0
  71. package/src/skills/run-runtime-doctor/script.py +21 -0
  72. package/src/skills/run-runtime-doctor/skill.json +25 -0
  73. package/src/skills_runtime.py +347 -0
  74. package/src/tools_menu.py +3 -2
  75. package/src/tools_sessions.py +126 -0
  76. package/src/user_context.py +46 -0
  77. package/templates/nexo_helper.py +45 -0
  78. package/templates/script-template.py +44 -0
  79. package/templates/skill-script-template.py +39 -0
  80. package/templates/skill-template.md +33 -0
@@ -0,0 +1 @@
1
+ """NEXO Doctor — unified modular diagnostic system."""
@@ -0,0 +1,52 @@
1
+ """Doctor output formatters — text and JSON."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import asdict
6
+
7
+ from doctor.models import DoctorReport
8
+
9
+
10
+ def format_report(report: DoctorReport, fmt: str = "text") -> str:
11
+ """Format a DoctorReport as text or JSON."""
12
+ if fmt == "json":
13
+ return json.dumps(asdict(report), indent=2, ensure_ascii=False)
14
+ return _format_text(report)
15
+
16
+
17
+ def _format_text(report: DoctorReport) -> str:
18
+ """Human-friendly text output."""
19
+ lines = []
20
+
21
+ # Header
22
+ icon = {"healthy": "✓", "degraded": "⚠", "critical": "✗"}.get(report.overall_status, "?")
23
+ lines.append(f"NEXO Doctor — {icon} {report.overall_status.upper()}")
24
+ lines.append(f" {report.counts.get('healthy', 0)} healthy, "
25
+ f"{report.counts.get('degraded', 0)} degraded, "
26
+ f"{report.counts.get('critical', 0)} critical "
27
+ f"({report.duration_ms}ms)")
28
+ lines.append("")
29
+
30
+ # Group by tier
31
+ current_tier = None
32
+ for check in report.checks:
33
+ if check.tier != current_tier:
34
+ current_tier = check.tier
35
+ lines.append(f"── {current_tier.upper()} ──")
36
+
37
+ icon = {"healthy": "✓", "degraded": "⚠", "critical": "✗"}.get(check.status, "?")
38
+ fixed = " [FIXED]" if check.fixed else ""
39
+ lines.append(f" {icon} {check.summary}{fixed}")
40
+
41
+ if check.status != "healthy":
42
+ for ev in check.evidence:
43
+ lines.append(f" → {ev}")
44
+ if check.repair_plan:
45
+ lines.append(" Fix:")
46
+ for step in check.repair_plan:
47
+ lines.append(f" • {step}")
48
+ if check.escalation_prompt:
49
+ lines.append(f" Escalation: {check.escalation_prompt}")
50
+
51
+ lines.append("")
52
+ return "\n".join(lines)
@@ -0,0 +1,44 @@
1
+ """Doctor data models — check results and report structure."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class DoctorCheck:
9
+ id: str
10
+ tier: str
11
+ status: str # healthy, degraded, critical
12
+ severity: str # info, warn, error
13
+ summary: str
14
+ evidence: list[str] = field(default_factory=list)
15
+ repair_plan: list[str] = field(default_factory=list)
16
+ escalation_prompt: str = ""
17
+ fixed: bool = False
18
+
19
+
20
+ @dataclass
21
+ class DoctorReport:
22
+ overall_status: str # healthy, degraded, critical
23
+ counts: dict = field(default_factory=dict)
24
+ checks: list[DoctorCheck] = field(default_factory=list)
25
+ duration_ms: int = 0
26
+
27
+ def add(self, check: DoctorCheck):
28
+ self.checks.append(check)
29
+
30
+ def compute_status(self):
31
+ """Compute overall status from individual checks."""
32
+ statuses = [c.status for c in self.checks]
33
+ if "critical" in statuses:
34
+ self.overall_status = "critical"
35
+ elif "degraded" in statuses:
36
+ self.overall_status = "degraded"
37
+ else:
38
+ self.overall_status = "healthy"
39
+ self.counts = {
40
+ "healthy": statuses.count("healthy"),
41
+ "degraded": statuses.count("degraded"),
42
+ "critical": statuses.count("critical"),
43
+ "total": len(statuses),
44
+ }
@@ -0,0 +1,42 @@
1
+ """Doctor orchestrator — runs providers by tier, aggregates results."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ from doctor.models import DoctorReport
7
+ from doctor.providers.boot import run_boot_checks
8
+ from doctor.providers.runtime import run_runtime_checks
9
+ from doctor.providers.deep import run_deep_checks
10
+
11
+
12
+ _TIER_RUNNERS = {
13
+ "boot": run_boot_checks,
14
+ "runtime": run_runtime_checks,
15
+ "deep": run_deep_checks,
16
+ }
17
+
18
+ _TIER_ORDER = ["boot", "runtime", "deep"]
19
+
20
+
21
+ def run_doctor(tier: str = "boot", fix: bool = False) -> DoctorReport:
22
+ """Run diagnostic checks for the specified tier(s).
23
+
24
+ Args:
25
+ tier: "boot", "runtime", "deep", or "all"
26
+ fix: If True, apply deterministic fixes where possible
27
+ """
28
+ report = DoctorReport(overall_status="healthy")
29
+ start = time.monotonic()
30
+
31
+ tiers = _TIER_ORDER if tier == "all" else [tier]
32
+
33
+ for t in tiers:
34
+ runner = _TIER_RUNNERS.get(t)
35
+ if runner:
36
+ checks = runner(fix=fix)
37
+ for check in checks:
38
+ report.add(check)
39
+
40
+ report.compute_status()
41
+ report.duration_ms = int((time.monotonic() - start) * 1000)
42
+ return report
@@ -0,0 +1 @@
1
+ """Doctor check providers — boot, runtime, deep."""
@@ -0,0 +1,206 @@
1
+ """Boot tier checks — fast, native, no repair. Target <100ms."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from doctor.models import DoctorCheck
10
+
11
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
12
+
13
+
14
+ def check_db_exists() -> DoctorCheck:
15
+ """Check that the main database file exists and is readable."""
16
+ db_path = NEXO_HOME / "data" / "nexo.db"
17
+ if db_path.is_file():
18
+ size_kb = db_path.stat().st_size / 1024
19
+ return DoctorCheck(
20
+ id="boot.db_exists",
21
+ tier="boot",
22
+ status="healthy",
23
+ severity="info",
24
+ summary=f"Database exists ({size_kb:.0f} KB)",
25
+ evidence=[str(db_path)],
26
+ )
27
+ return DoctorCheck(
28
+ id="boot.db_exists",
29
+ tier="boot",
30
+ status="critical",
31
+ severity="error",
32
+ summary="Database file not found",
33
+ evidence=[f"Expected: {db_path}"],
34
+ repair_plan=["Run nexo-brain to initialize the database"],
35
+ escalation_prompt="NEXO database missing — server cannot start without it.",
36
+ )
37
+
38
+
39
+ def check_required_dirs() -> DoctorCheck:
40
+ """Check that required NEXO_HOME directories exist."""
41
+ required = ["data", "scripts", "plugins", "crons", "hooks", "coordination", "operations", "logs"]
42
+ missing = [d for d in required if not (NEXO_HOME / d).is_dir()]
43
+
44
+ if not missing:
45
+ return DoctorCheck(
46
+ id="boot.required_dirs",
47
+ tier="boot",
48
+ status="healthy",
49
+ severity="info",
50
+ summary=f"All {len(required)} required directories present",
51
+ )
52
+
53
+ return DoctorCheck(
54
+ id="boot.required_dirs",
55
+ tier="boot",
56
+ status="degraded" if len(missing) < 3 else "critical",
57
+ severity="warn" if len(missing) < 3 else "error",
58
+ summary=f"{len(missing)} required director{'y' if len(missing) == 1 else 'ies'} missing",
59
+ evidence=[f"Missing: {d}" for d in missing],
60
+ repair_plan=[f"mkdir -p {NEXO_HOME / d}" for d in missing],
61
+ )
62
+
63
+
64
+ def check_disk_space() -> DoctorCheck:
65
+ """Check disk free space on NEXO_HOME partition."""
66
+ try:
67
+ usage = shutil.disk_usage(str(NEXO_HOME))
68
+ free_gb = usage.free / (1024 ** 3)
69
+ pct_free = (usage.free / usage.total) * 100
70
+
71
+ if free_gb < 1:
72
+ return DoctorCheck(
73
+ id="boot.disk_space",
74
+ tier="boot",
75
+ status="critical",
76
+ severity="error",
77
+ summary=f"Very low disk space: {free_gb:.1f} GB free ({pct_free:.0f}%)",
78
+ evidence=[f"Total: {usage.total / (1024**3):.0f} GB, Free: {free_gb:.1f} GB"],
79
+ repair_plan=["Free up disk space — NEXO needs at least 1 GB for normal operation"],
80
+ escalation_prompt="Disk space critically low — backups and logs may fail.",
81
+ )
82
+ elif free_gb < 5:
83
+ return DoctorCheck(
84
+ id="boot.disk_space",
85
+ tier="boot",
86
+ status="degraded",
87
+ severity="warn",
88
+ summary=f"Low disk space: {free_gb:.1f} GB free ({pct_free:.0f}%)",
89
+ evidence=[f"Total: {usage.total / (1024**3):.0f} GB, Free: {free_gb:.1f} GB"],
90
+ )
91
+ return DoctorCheck(
92
+ id="boot.disk_space",
93
+ tier="boot",
94
+ status="healthy",
95
+ severity="info",
96
+ summary=f"Disk space OK: {free_gb:.0f} GB free ({pct_free:.0f}%)",
97
+ )
98
+ except Exception as e:
99
+ return DoctorCheck(
100
+ id="boot.disk_space",
101
+ tier="boot",
102
+ status="degraded",
103
+ severity="warn",
104
+ summary=f"Could not check disk space: {e}",
105
+ )
106
+
107
+
108
+ def check_wrapper_scripts() -> DoctorCheck:
109
+ """Check that cron wrapper script exists."""
110
+ wrapper = NEXO_HOME / "scripts" / "nexo-cron-wrapper.sh"
111
+ if wrapper.is_file():
112
+ return DoctorCheck(
113
+ id="boot.wrapper_scripts",
114
+ tier="boot",
115
+ status="healthy",
116
+ severity="info",
117
+ summary="Cron wrapper script present",
118
+ )
119
+ return DoctorCheck(
120
+ id="boot.wrapper_scripts",
121
+ tier="boot",
122
+ status="degraded",
123
+ severity="warn",
124
+ summary="Cron wrapper script missing",
125
+ evidence=[f"Expected: {wrapper}"],
126
+ repair_plan=["Run nexo-brain to reinstall wrapper scripts"],
127
+ )
128
+
129
+
130
+ def check_python_runtime() -> DoctorCheck:
131
+ """Check Python interpreter is suitable."""
132
+ version = sys.version_info
133
+ if version >= (3, 10):
134
+ return DoctorCheck(
135
+ id="boot.python_runtime",
136
+ tier="boot",
137
+ status="healthy",
138
+ severity="info",
139
+ summary=f"Python {version.major}.{version.minor}.{version.micro}",
140
+ )
141
+ return DoctorCheck(
142
+ id="boot.python_runtime",
143
+ tier="boot",
144
+ status="degraded",
145
+ severity="warn",
146
+ summary=f"Python {version.major}.{version.minor} — 3.10+ recommended",
147
+ evidence=[sys.executable],
148
+ )
149
+
150
+
151
+ def check_config_parse() -> DoctorCheck:
152
+ """Check schedule.json parses correctly if present."""
153
+ schedule_file = NEXO_HOME / "config" / "schedule.json"
154
+ if not schedule_file.exists():
155
+ return DoctorCheck(
156
+ id="boot.config_parse",
157
+ tier="boot",
158
+ status="healthy",
159
+ severity="info",
160
+ summary="No schedule.json (using defaults)",
161
+ )
162
+ try:
163
+ import json
164
+ json.loads(schedule_file.read_text())
165
+ return DoctorCheck(
166
+ id="boot.config_parse",
167
+ tier="boot",
168
+ status="healthy",
169
+ severity="info",
170
+ summary="schedule.json parses OK",
171
+ )
172
+ except Exception as e:
173
+ return DoctorCheck(
174
+ id="boot.config_parse",
175
+ tier="boot",
176
+ status="degraded",
177
+ severity="warn",
178
+ summary=f"schedule.json parse error: {e}",
179
+ repair_plan=["Fix JSON syntax in schedule.json or delete to use defaults"],
180
+ )
181
+
182
+
183
+ def run_boot_checks(fix: bool = False) -> list[DoctorCheck]:
184
+ """Run all boot-tier checks."""
185
+ checks = [
186
+ check_db_exists(),
187
+ check_required_dirs(),
188
+ check_disk_space(),
189
+ check_wrapper_scripts(),
190
+ check_python_runtime(),
191
+ check_config_parse(),
192
+ ]
193
+
194
+ if fix:
195
+ for check in checks:
196
+ if check.id == "boot.required_dirs" and check.status != "healthy":
197
+ # Deterministic fix: create missing directories
198
+ for plan in check.repair_plan:
199
+ if plan.startswith("mkdir"):
200
+ dir_path = plan.split("mkdir -p ")[-1]
201
+ Path(dir_path).mkdir(parents=True, exist_ok=True)
202
+ check.fixed = True
203
+ check.status = "healthy"
204
+ check.summary += " (fixed)"
205
+
206
+ return checks
@@ -0,0 +1,292 @@
1
+ """Deep tier checks — read existing artifacts for richer validation. Target <60s."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from doctor.models import DoctorCheck
10
+
11
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
12
+
13
+ # Freshness thresholds
14
+ SELF_AUDIT_FRESHNESS = 86400 * 2 # 2 days (runs daily)
15
+ PREFLIGHT_FRESHNESS = 86400 # 1 day
16
+ WATCHDOG_SMOKE_FRESHNESS = 86400 # 1 day
17
+
18
+
19
+ def _file_age_seconds(path: Path) -> float | None:
20
+ try:
21
+ if path.is_file():
22
+ return time.time() - path.stat().st_mtime
23
+ except Exception:
24
+ pass
25
+ return None
26
+
27
+
28
+ def _load_json(path: Path) -> dict:
29
+ return json.loads(path.read_text())
30
+
31
+
32
+ def check_self_audit_summary() -> DoctorCheck:
33
+ """Check latest self-audit summary exists and is recent."""
34
+ summary_file = NEXO_HOME / "logs" / "self-audit-summary.json"
35
+ age = _file_age_seconds(summary_file)
36
+
37
+ if age is None:
38
+ return DoctorCheck(
39
+ id="deep.self_audit",
40
+ tier="deep",
41
+ status="degraded",
42
+ severity="warn",
43
+ summary="Self-audit summary not found",
44
+ evidence=[f"Expected: {summary_file}"],
45
+ repair_plan=["Check if daily self-audit cron is installed"],
46
+ )
47
+
48
+ age_hours = age / 3600
49
+ if age > SELF_AUDIT_FRESHNESS:
50
+ return DoctorCheck(
51
+ id="deep.self_audit",
52
+ tier="deep",
53
+ status="degraded",
54
+ severity="warn",
55
+ summary=f"Self-audit summary stale ({age_hours:.0f}h old)",
56
+ evidence=[f"Last modified {age_hours:.0f} hours ago, threshold {SELF_AUDIT_FRESHNESS // 3600}h"],
57
+ )
58
+
59
+ try:
60
+ data = _load_json(summary_file)
61
+ counts = data.get("counts") or {}
62
+ error_count = int(counts.get("error", 0) or 0)
63
+ warn_count = int(counts.get("warn", 0) or 0)
64
+ findings = data.get("findings") or []
65
+ if error_count > 0:
66
+ status = "critical"
67
+ severity = "error"
68
+ elif warn_count > 0:
69
+ status = "degraded"
70
+ severity = "warn"
71
+ else:
72
+ status = "healthy"
73
+ severity = "info"
74
+ return DoctorCheck(
75
+ id="deep.self_audit",
76
+ tier="deep",
77
+ status=status,
78
+ severity=severity,
79
+ summary=(
80
+ f"Self-audit: {len(findings)} findings "
81
+ f"({error_count} error, {warn_count} warn; {age_hours:.0f}h ago)"
82
+ ),
83
+ )
84
+ except Exception as e:
85
+ return DoctorCheck(
86
+ id="deep.self_audit",
87
+ tier="deep",
88
+ status="degraded",
89
+ severity="warn",
90
+ summary=f"Self-audit summary unreadable ({age_hours:.0f}h ago)",
91
+ evidence=[str(e)],
92
+ )
93
+
94
+
95
+ def check_schema_version() -> DoctorCheck:
96
+ """Check DB schema version is present and reasonable."""
97
+ try:
98
+ import sqlite3
99
+ db_path = NEXO_HOME / "data" / "nexo.db"
100
+ if not db_path.is_file():
101
+ return DoctorCheck(
102
+ id="deep.schema_version",
103
+ tier="deep",
104
+ status="degraded",
105
+ severity="warn",
106
+ summary="No database to check schema",
107
+ )
108
+ conn = sqlite3.connect(str(db_path), timeout=2)
109
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
110
+ conn.close()
111
+ return DoctorCheck(
112
+ id="deep.schema_version",
113
+ tier="deep",
114
+ status="healthy",
115
+ severity="info",
116
+ summary=f"DB schema version: {version}",
117
+ )
118
+ except Exception as e:
119
+ return DoctorCheck(
120
+ id="deep.schema_version",
121
+ tier="deep",
122
+ status="degraded",
123
+ severity="warn",
124
+ summary=f"Schema check failed: {e}",
125
+ )
126
+
127
+
128
+ def check_preflight_summary() -> DoctorCheck:
129
+ """Check runtime preflight summary."""
130
+ summary_file = NEXO_HOME / "logs" / "runtime-preflight-summary.json"
131
+ age = _file_age_seconds(summary_file)
132
+
133
+ if age is None:
134
+ return DoctorCheck(
135
+ id="deep.preflight",
136
+ tier="deep",
137
+ status="healthy",
138
+ severity="info",
139
+ summary="No preflight summary (optional)",
140
+ )
141
+
142
+ age_hours = age / 3600
143
+ if age > PREFLIGHT_FRESHNESS:
144
+ return DoctorCheck(
145
+ id="deep.preflight",
146
+ tier="deep",
147
+ status="degraded",
148
+ severity="warn",
149
+ summary=f"Preflight summary stale ({age_hours:.0f}h old)",
150
+ )
151
+ try:
152
+ data = _load_json(summary_file)
153
+ ok = data.get("ok")
154
+ checks = data.get("checks") or {}
155
+ errors = data.get("errors") or []
156
+ if ok is True:
157
+ return DoctorCheck(
158
+ id="deep.preflight",
159
+ tier="deep",
160
+ status="healthy",
161
+ severity="info",
162
+ summary=f"Runtime preflight OK ({len(checks)} checks, {age_hours:.0f}h ago)",
163
+ )
164
+ return DoctorCheck(
165
+ id="deep.preflight",
166
+ tier="deep",
167
+ status="critical",
168
+ severity="error",
169
+ summary=f"Runtime preflight failed ({len(errors)} errors, {age_hours:.0f}h ago)",
170
+ evidence=errors[:5],
171
+ )
172
+ except Exception as e:
173
+ return DoctorCheck(
174
+ id="deep.preflight",
175
+ tier="deep",
176
+ status="degraded",
177
+ severity="warn",
178
+ summary=f"Preflight summary unreadable ({age_hours:.0f}h ago)",
179
+ evidence=[str(e)],
180
+ )
181
+
182
+
183
+ def check_watchdog_smoke() -> DoctorCheck:
184
+ """Check watchdog smoke summary."""
185
+ summary_file = NEXO_HOME / "logs" / "watchdog-smoke-summary.json"
186
+ age = _file_age_seconds(summary_file)
187
+
188
+ if age is None:
189
+ return DoctorCheck(
190
+ id="deep.watchdog_smoke",
191
+ tier="deep",
192
+ status="healthy",
193
+ severity="info",
194
+ summary="No watchdog smoke summary (optional)",
195
+ )
196
+
197
+ age_hours = age / 3600
198
+ if age > WATCHDOG_SMOKE_FRESHNESS:
199
+ return DoctorCheck(
200
+ id="deep.watchdog_smoke",
201
+ tier="deep",
202
+ status="degraded",
203
+ severity="warn",
204
+ summary=f"Watchdog smoke summary stale ({age_hours:.0f}h old)",
205
+ )
206
+
207
+ try:
208
+ data = _load_json(summary_file)
209
+ ok = data.get("ok")
210
+ findings = data.get("findings") or []
211
+ error_count = sum(1 for finding in findings if finding.get("severity") == "ERROR")
212
+ if ok is True:
213
+ return DoctorCheck(
214
+ id="deep.watchdog_smoke",
215
+ tier="deep",
216
+ status="healthy",
217
+ severity="info",
218
+ summary=f"Watchdog smoke OK ({len(findings)} findings, {age_hours:.0f}h ago)",
219
+ )
220
+ return DoctorCheck(
221
+ id="deep.watchdog_smoke",
222
+ tier="deep",
223
+ status="critical",
224
+ severity="error",
225
+ summary=f"Watchdog smoke failed ({error_count} errors, {age_hours:.0f}h ago)",
226
+ evidence=[finding.get("msg", "") for finding in findings[:5]],
227
+ )
228
+ except Exception as e:
229
+ return DoctorCheck(
230
+ id="deep.watchdog_smoke",
231
+ tier="deep",
232
+ status="degraded",
233
+ severity="warn",
234
+ summary=f"Watchdog smoke summary unreadable ({age_hours:.0f}h ago)",
235
+ evidence=[str(e)],
236
+ )
237
+
238
+
239
+ def check_learning_count() -> DoctorCheck:
240
+ """Check learning count as a health proxy."""
241
+ try:
242
+ import sqlite3
243
+ db_path = NEXO_HOME / "data" / "nexo.db"
244
+ if not db_path.is_file():
245
+ return DoctorCheck(
246
+ id="deep.learning_count",
247
+ tier="deep",
248
+ status="healthy",
249
+ severity="info",
250
+ summary="No DB to check learnings",
251
+ )
252
+ conn = sqlite3.connect(str(db_path), timeout=2)
253
+ tables = conn.execute(
254
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='learnings'"
255
+ ).fetchone()
256
+ if not tables:
257
+ conn.close()
258
+ return DoctorCheck(
259
+ id="deep.learning_count",
260
+ tier="deep",
261
+ status="healthy",
262
+ severity="info",
263
+ summary="No learnings table yet",
264
+ )
265
+ count = conn.execute("SELECT COUNT(*) FROM learnings WHERE archived=0").fetchone()[0]
266
+ conn.close()
267
+ return DoctorCheck(
268
+ id="deep.learning_count",
269
+ tier="deep",
270
+ status="healthy",
271
+ severity="info",
272
+ summary=f"{count} active learnings in memory",
273
+ )
274
+ except Exception as e:
275
+ return DoctorCheck(
276
+ id="deep.learning_count",
277
+ tier="deep",
278
+ status="healthy",
279
+ severity="info",
280
+ summary=f"Learning check skipped: {e}",
281
+ )
282
+
283
+
284
+ def run_deep_checks(fix: bool = False) -> list[DoctorCheck]:
285
+ """Run all deep-tier checks. Read-only."""
286
+ return [
287
+ check_self_audit_summary(),
288
+ check_schema_version(),
289
+ check_preflight_summary(),
290
+ check_watchdog_smoke(),
291
+ check_learning_count(),
292
+ ]