nexo-brain 7.23.2 → 7.23.4

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -1
  3. package/package.json +1 -1
  4. package/scripts/sync_release_artifacts.py +28 -0
  5. package/src/auto_update.py +25 -47
  6. package/src/automation_reconciler.py +383 -0
  7. package/src/automation_supervisor.py +86 -9
  8. package/src/backup_retention.py +70 -0
  9. package/src/cli.py +55 -2
  10. package/src/cognitive/_core.py +4 -3
  11. package/src/cognitive_paths.py +194 -0
  12. package/src/dashboard/app.py +2 -1
  13. package/src/db/_episodic.py +85 -7
  14. package/src/db/_schema.py +81 -0
  15. package/src/db/_skills.py +3 -3
  16. package/src/disk_recovery/__init__.py +11 -0
  17. package/src/disk_recovery/handlers/__init__.py +1 -0
  18. package/src/disk_recovery/handlers/common.py +37 -0
  19. package/src/disk_recovery/handlers/macos.py +39 -0
  20. package/src/disk_recovery/handlers/windows.py +49 -0
  21. package/src/disk_recovery/registry.py +135 -0
  22. package/src/doctor/providers/boot.py +115 -15
  23. package/src/kg_populate.py +2 -5
  24. package/src/paths.py +321 -5
  25. package/src/plugins/update.py +14 -36
  26. package/src/pre_answer_router.py +21 -0
  27. package/src/runtime_service.py +30 -3
  28. package/src/runtime_versioning.py +272 -10
  29. package/src/script_registry.py +3 -2
  30. package/src/scripts/backfill_task_owner.py +10 -4
  31. package/src/scripts/deep-sleep/apply_findings.py +2 -5
  32. package/src/scripts/deep-sleep/collect.py +2 -5
  33. package/src/scripts/nexo-cognitive-decay.py +2 -1
  34. package/src/scripts/nexo-daily-self-audit.py +36 -10
  35. package/src/scripts/nexo-followup-runner.py +1 -1
  36. package/src/scripts/nexo-immune.py +2 -1
  37. package/src/scripts/nexo-migrate.py +2 -3
  38. package/src/scripts/post_disk_recovery_sweep.py +75 -0
  39. package/src/scripts/prune_runtime_backups.py +78 -11
  40. package/src/server.py +13 -1
  41. package/src/storage_router.py +2 -3
  42. package/src/support_snapshot.py +25 -0
  43. package/src/transcript_index.py +234 -0
  44. package/src/transcript_utils.py +31 -8
  45. package/src/user_data_portability.py +2 -3
  46. package/tool-enforcement-map.json +15 -0
@@ -9,6 +9,7 @@ handlers.
9
9
  """
10
10
 
11
11
  import asyncio
12
+ import hashlib
12
13
  import json
13
14
  import os
14
15
  import signal
@@ -135,11 +136,22 @@ def read_service_state() -> dict[str, Any]:
135
136
  return {}
136
137
 
137
138
 
139
+ def _new_runtime_instance_id(payload: dict[str, Any]) -> str:
140
+ seed = "|".join(
141
+ str(payload.get(key) or "")
142
+ for key in ("server_path", "runtime_version", "runtime_fingerprint", "started_at", "pid")
143
+ )
144
+ if not seed.strip("|"):
145
+ seed = f"{current_server_path()}|{os.getpid()}|{time.time()}"
146
+ return "rt-" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
147
+
148
+
138
149
  def write_service_state(state: dict[str, Any]) -> None:
139
150
  path = service_state_path()
140
151
  tmp = path.with_suffix(path.suffix + ".tmp")
141
152
  payload = dict(state)
142
153
  payload.update(current_runtime_identity())
154
+ payload["runtime_instance_id"] = str(payload.get("runtime_instance_id") or _new_runtime_instance_id(payload))
143
155
  payload["updated_at"] = time.time()
144
156
  tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
145
157
  os.replace(tmp, path)
@@ -242,17 +254,24 @@ def current_server_path() -> Path:
242
254
 
243
255
  def current_runtime_identity() -> dict[str, str]:
244
256
  try:
245
- from runtime_versioning import compute_mcp_runtime_fingerprint, read_version_for_path
257
+ from runtime_versioning import compute_mcp_runtime_fingerprint, read_version_for_path, runtime_generation
246
258
 
247
259
  root = current_server_path().parent
248
260
  version = read_version_for_path(root) or read_version_for_path(root.parent)
261
+ fingerprint = compute_mcp_runtime_fingerprint(root, use_cache=True)
249
262
  return {
250
263
  "runtime_version": version,
251
- "runtime_fingerprint": compute_mcp_runtime_fingerprint(root, use_cache=True),
264
+ "runtime_fingerprint": fingerprint,
265
+ "runtime_generation": runtime_generation(version, fingerprint, str(root)),
252
266
  "server_path": str(current_server_path()),
253
267
  }
254
268
  except Exception:
255
- return {"runtime_version": "", "runtime_fingerprint": "", "server_path": str(current_server_path())}
269
+ return {
270
+ "runtime_version": "",
271
+ "runtime_fingerprint": "",
272
+ "runtime_generation": "unknown",
273
+ "server_path": str(current_server_path()),
274
+ }
256
275
 
257
276
 
258
277
  def state_matches_current_runtime(state: dict[str, Any]) -> bool:
@@ -268,6 +287,11 @@ def state_matches_current_runtime(state: dict[str, Any]) -> bool:
268
287
  if current_fp and state_fp and current_fp != state_fp:
269
288
  return False
270
289
 
290
+ current_generation = str(current.get("runtime_generation") or "").strip()
291
+ state_generation = str(state.get("runtime_generation") or "").strip()
292
+ if current_generation and state_generation and current_generation != state_generation:
293
+ return False
294
+
271
295
  current_version = str(current.get("runtime_version") or "").strip()
272
296
  state_version = str(state.get("runtime_version") or "").strip()
273
297
  if current_version and state_version and current_version != state_version:
@@ -410,8 +434,11 @@ def runtime_service_status() -> dict[str, Any]:
410
434
  "stale": bool(state and not state_matches_current_runtime(state)),
411
435
  "runtime_version": current.get("runtime_version", ""),
412
436
  "runtime_fingerprint": current.get("runtime_fingerprint", ""),
437
+ "runtime_generation": current.get("runtime_generation", "unknown"),
438
+ "runtime_instance_id": str(state.get("runtime_instance_id") or ""),
413
439
  "state_runtime_version": str(state.get("runtime_version") or ""),
414
440
  "state_runtime_fingerprint": str(state.get("runtime_fingerprint") or ""),
441
+ "state_runtime_generation": str(state.get("runtime_generation") or ""),
415
442
  "state_path": str(service_state_path()),
416
443
  "log_path": str(service_log_path()),
417
444
  "server_path": str(current_server_path()),
@@ -19,7 +19,8 @@ import paths
19
19
 
20
20
 
21
21
  CONTINUITY_API_LEVEL = 1
22
- MCP_STATUS_SCHEMA_VERSION = 2
22
+ MCP_STATUS_SCHEMA_VERSION = 3
23
+ CLIENT_STATE_SCHEMA_VERSION = 1
23
24
  PROCESS_VERSION = ""
24
25
  PROCESS_FINGERPRINT = ""
25
26
 
@@ -61,11 +62,11 @@ RESTART_ALLOWLIST = {
61
62
  "nexo_continuity_snapshot_read",
62
63
  "nexo_continuity_resume_bundle",
63
64
  "nexo_continuity_audit",
64
- # v0.32.5 — añadidas las read-only tools que el protocolo CORE llama
65
- # justo después de `nexo_startup` (memory recall, reminders, followups,
66
- # context, doctor). Antes quedaban bloqueadas por mcp_restart_required
67
- # tras `nexo update` con sesión activa Nero parecía amnésico hasta
68
- # que el cliente se cerraba/reabría.
65
+ # v0.32.5 — read-only tools called by the CORE protocol immediately after
66
+ # `nexo_startup` (memory recall, reminders, followups, context, doctor).
67
+ # Without this allowlist they were blocked by mcp_restart_required after
68
+ # `nexo update` while a session was active, making continuity appear lost
69
+ # until the client was closed and reopened.
69
70
  "nexo_smart_startup",
70
71
  "nexo_session_diary_read",
71
72
  "nexo_session_diary_write",
@@ -178,6 +179,135 @@ def restart_required_marker_path() -> Path:
178
179
  return paths.operations_dir() / "mcp-restart-required.json"
179
180
 
180
181
 
182
+ def mcp_client_state_path() -> Path:
183
+ return paths.runtime_state_dir() / "mcp-client-state.json"
184
+
185
+
186
+ def runtime_generation(version: str = "", fingerprint: str = "", root: str = "") -> str:
187
+ seed = "|".join(part for part in (version, fingerprint, root) if str(part or "").strip())
188
+ if not seed:
189
+ return "unknown"
190
+ return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
191
+
192
+
193
+ def _read_mcp_client_state_file() -> dict:
194
+ path = mcp_client_state_path()
195
+ if not path.is_file():
196
+ return {
197
+ "schema_version": CLIENT_STATE_SCHEMA_VERSION,
198
+ "clients": {},
199
+ }
200
+ try:
201
+ payload = json.loads(path.read_text(encoding="utf-8"))
202
+ except Exception:
203
+ return {
204
+ "schema_version": CLIENT_STATE_SCHEMA_VERSION,
205
+ "clients": {},
206
+ "corrupt": True,
207
+ }
208
+ if not isinstance(payload, dict):
209
+ return {
210
+ "schema_version": CLIENT_STATE_SCHEMA_VERSION,
211
+ "clients": {},
212
+ "corrupt": True,
213
+ }
214
+ clients = payload.get("clients")
215
+ if not isinstance(clients, dict):
216
+ payload["clients"] = {}
217
+ payload.setdefault("schema_version", CLIENT_STATE_SCHEMA_VERSION)
218
+ return payload
219
+
220
+
221
+ def _write_mcp_client_state_file(payload: dict) -> None:
222
+ payload = dict(payload)
223
+ payload["schema_version"] = CLIENT_STATE_SCHEMA_VERSION
224
+ payload["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
225
+ _write_json_atomic(mcp_client_state_path(), payload)
226
+
227
+
228
+ def read_mcp_client_states() -> dict:
229
+ """Return the persisted per-client MCP readiness registry."""
230
+ payload = _read_mcp_client_state_file()
231
+ clients = payload.get("clients")
232
+ if not isinstance(clients, dict):
233
+ clients = {}
234
+ return {
235
+ "schema_version": CLIENT_STATE_SCHEMA_VERSION,
236
+ "path": str(mcp_client_state_path()),
237
+ "clients": clients,
238
+ "corrupt": bool(payload.get("corrupt")),
239
+ "updated_at": str(payload.get("updated_at") or ""),
240
+ }
241
+
242
+
243
+ def _probe_reason_code(probe: dict) -> str:
244
+ if not probe.get("probe_ok", probe.get("ok", False)):
245
+ return str(probe.get("error") or "mcp_probe_failed")
246
+ try:
247
+ tool_count = int(probe.get("tool_count") or 0)
248
+ except Exception:
249
+ tool_count = 0
250
+ if tool_count <= 0:
251
+ return "tools_missing"
252
+ missing = probe.get("missing_required_tools")
253
+ has_required_tools_contract = isinstance(
254
+ probe.get("required_tools_present"), bool
255
+ ) or isinstance(missing, list)
256
+ if not has_required_tools_contract:
257
+ return "required_tools_contract_missing"
258
+ if probe.get("required_tools_present") is False:
259
+ return "required_tools_missing"
260
+ if isinstance(missing, list) and missing:
261
+ return "required_tools_missing"
262
+ return "ready"
263
+
264
+
265
+ def record_mcp_client_probe(*, client: str = "", probe: dict | None = None) -> dict:
266
+ """Persist the latest probe result for one MCP client."""
267
+ normalized = _normalize_restart_client(client or (probe or {}).get("client", ""))
268
+ if not normalized:
269
+ return {"ok": False, "error": "unknown_client"}
270
+ probe = dict(probe or {})
271
+ installed_version_value = str(probe.get("installed_version") or installed_runtime_version() or "").strip()
272
+ installed_fp = str(probe.get("installed_fingerprint") or installed_runtime_fingerprint() or "").strip()
273
+ root = str(active_runtime_root())
274
+ generation = str(probe.get("runtime_generation") or runtime_generation(installed_version_value, installed_fp, root))
275
+ reason_code = _probe_reason_code(probe)
276
+ probe_ok = reason_code == "ready"
277
+ try:
278
+ tool_count = int(probe.get("tool_count") or 0)
279
+ except Exception:
280
+ tool_count = 0
281
+ missing_required = probe.get("missing_required_tools")
282
+ if not isinstance(missing_required, list):
283
+ missing_required = []
284
+ required_tools_present_raw = probe.get("required_tools_present")
285
+ required_tools_present = (
286
+ required_tools_present_raw
287
+ if isinstance(required_tools_present_raw, bool)
288
+ else False
289
+ )
290
+
291
+ payload = _read_mcp_client_state_file()
292
+ clients = dict(payload.get("clients") or {})
293
+ row = {
294
+ "client": normalized,
295
+ "last_seen_generation": generation,
296
+ "last_tool_count": tool_count,
297
+ "last_probe_ok": probe_ok,
298
+ "last_fingerprint": installed_fp,
299
+ "last_probe_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
300
+ "required_tools_present": required_tools_present,
301
+ "missing_required_tools": missing_required,
302
+ "reason_code": reason_code,
303
+ "client_action": "ready" if probe_ok else "reprobe",
304
+ }
305
+ clients[normalized] = row
306
+ payload["clients"] = clients
307
+ _write_mcp_client_state_file(payload)
308
+ return {"ok": True, **row}
309
+
310
+
181
311
  def fingerprint_cache_path() -> Path:
182
312
  """Where the runtime fingerprint cache lives.
183
313
 
@@ -634,8 +764,9 @@ def clear_restart_required_marker(
634
764
  pending_clients = {k: v for k, v in clients.items() if v != "ok"}
635
765
  effective_installed = str(installed_version or payload.get("to_version") or "").strip()
636
766
  effective_process = str(process_version or "").strip()
767
+ marker_to_fingerprint = str(payload.get("to_fingerprint") or "").strip()
637
768
  effective_installed_fp = str(
638
- installed_fingerprint or payload.get("to_fingerprint") or ""
769
+ installed_fingerprint or marker_to_fingerprint or ""
639
770
  ).strip()
640
771
  effective_process_fp = str(
641
772
  process_fingerprint or PROCESS_FINGERPRINT or ""
@@ -643,8 +774,9 @@ def clear_restart_required_marker(
643
774
  if pending_clients:
644
775
  _write_json_atomic(path, payload)
645
776
  return {"ok": True, "cleared": False, "path": str(path), "pending_clients": pending_clients}
646
- # Prefer fingerprint match when both sides have it; fall back to version
647
- # comparison only when one side is missing or unknown.
777
+ # Prefer fingerprint match when both sides have it. For markers that were
778
+ # created with a target fingerprint, do not fall back to version-only
779
+ # clearing: matching versions can still be a stale in-place source update.
648
780
  if (
649
781
  effective_installed_fp
650
782
  and effective_process_fp
@@ -658,6 +790,14 @@ def clear_restart_required_marker(
658
790
  "path": str(path),
659
791
  "pending_reason": "process_fingerprint_mismatch",
660
792
  }
793
+ elif marker_to_fingerprint:
794
+ _write_json_atomic(path, payload)
795
+ return {
796
+ "ok": True,
797
+ "cleared": False,
798
+ "path": str(path),
799
+ "pending_reason": "process_fingerprint_missing",
800
+ }
661
801
  elif effective_installed and effective_process and effective_installed != effective_process:
662
802
  _write_json_atomic(path, payload)
663
803
  return {
@@ -687,6 +827,7 @@ def resolve_restart_required(
687
827
  process = str(process_version or PROCESS_VERSION or installed).strip()
688
828
  installed_fp = str(installed_fingerprint or installed_runtime_fingerprint() or "").strip()
689
829
  process_fp = str(process_fingerprint or PROCESS_FINGERPRINT or "").strip()
830
+ marker_fp = str(marker.get("to_fingerprint") or "").strip()
690
831
  restart_required = False
691
832
  reason = ""
692
833
  client_action = ""
@@ -706,6 +847,9 @@ def resolve_restart_required(
706
847
  # fingerprint change and therefore never reach this branch.
707
848
  restart_required = True
708
849
  reason = reason or "fingerprint_mismatch"
850
+ elif marker.get("required") and marker_fp and (not process_fp or process_fp == "unknown"):
851
+ restart_required = True
852
+ reason = reason or "process_fingerprint_missing"
709
853
  elif not fingerprint_usable and installed and process and installed != process:
710
854
  # Fallback: when fingerprint can't be computed (missing source tree,
711
855
  # unreadable files, fresh install), fall back to the legacy version
@@ -728,6 +872,105 @@ def resolve_restart_required(
728
872
  }
729
873
 
730
874
 
875
+ def _mcp_client_readiness(
876
+ *,
877
+ client: str,
878
+ state: dict,
879
+ installed_version_value: str,
880
+ installed_fp: str,
881
+ service_status: dict,
882
+ ) -> dict:
883
+ generation = runtime_generation(installed_version_value, installed_fp, str(active_runtime_root()))
884
+ process_fp = str(state.get("process_fingerprint") or "").strip()
885
+ service_ok = bool(service_status.get("ok", True))
886
+ fingerprint_ready = (
887
+ bool(installed_fp)
888
+ and bool(process_fp)
889
+ and process_fp != "unknown"
890
+ and installed_fp == process_fp
891
+ )
892
+ global_ready = (
893
+ not bool(state.get("restart_required"))
894
+ and fingerprint_ready
895
+ and service_ok
896
+ )
897
+ if not service_ok:
898
+ global_reason = "runtime_service_unavailable"
899
+ elif not installed_fp:
900
+ global_reason = "installed_fingerprint_missing"
901
+ elif not process_fp or process_fp == "unknown":
902
+ global_reason = "process_fingerprint_missing"
903
+ elif installed_fp != process_fp:
904
+ global_reason = "process_fingerprint_mismatch"
905
+ else:
906
+ global_reason = "ready"
907
+ if state.get("restart_required"):
908
+ return {
909
+ "runtime_generation": generation,
910
+ "global_ready": False,
911
+ "client_ready": False,
912
+ "reason_code": str(state.get("reason") or "restart_required"),
913
+ "client_action": str(state.get("client_action") or "restart_client"),
914
+ "client_state": {},
915
+ }
916
+ if not client:
917
+ return {
918
+ "runtime_generation": generation,
919
+ "global_ready": global_ready,
920
+ "client_ready": global_ready,
921
+ "reason_code": "ready" if global_ready else global_reason,
922
+ "client_action": "ready" if global_ready else "reprobe",
923
+ "client_state": {},
924
+ }
925
+ if not global_ready:
926
+ return {
927
+ "runtime_generation": generation,
928
+ "global_ready": False,
929
+ "client_ready": False,
930
+ "reason_code": global_reason,
931
+ "client_action": "reprobe",
932
+ "client_state": {},
933
+ }
934
+
935
+ registry = read_mcp_client_states()
936
+ client_state = dict((registry.get("clients") or {}).get(client) or {})
937
+ if not client_state:
938
+ return {
939
+ "runtime_generation": generation,
940
+ "global_ready": global_ready,
941
+ "client_ready": False,
942
+ "reason_code": "client_probe_missing",
943
+ "client_action": "reprobe",
944
+ "client_state": {},
945
+ }
946
+ if str(client_state.get("last_seen_generation") or "") != generation:
947
+ return {
948
+ "runtime_generation": generation,
949
+ "global_ready": global_ready,
950
+ "client_ready": False,
951
+ "reason_code": "client_generation_stale",
952
+ "client_action": "reprobe",
953
+ "client_state": client_state,
954
+ }
955
+ if not client_state.get("last_probe_ok"):
956
+ return {
957
+ "runtime_generation": generation,
958
+ "global_ready": global_ready,
959
+ "client_ready": False,
960
+ "reason_code": str(client_state.get("reason_code") or "client_probe_failed"),
961
+ "client_action": str(client_state.get("client_action") or "reprobe"),
962
+ "client_state": client_state,
963
+ }
964
+ return {
965
+ "runtime_generation": generation,
966
+ "global_ready": global_ready,
967
+ "client_ready": bool(global_ready),
968
+ "reason_code": "ready" if global_ready else global_reason,
969
+ "client_action": "ready" if global_ready else "reprobe",
970
+ "client_state": client_state,
971
+ }
972
+
973
+
731
974
  def build_mcp_status(*, client: str = "") -> dict:
732
975
  client = _normalize_restart_client(client)
733
976
  state = resolve_restart_required(client=client)
@@ -744,6 +987,14 @@ def build_mcp_status(*, client: str = "") -> dict:
744
987
  "error": "runtime_service_status_unavailable",
745
988
  "message": str(exc)[:300],
746
989
  }
990
+ readiness = _mcp_client_readiness(
991
+ client=client,
992
+ state=state,
993
+ installed_version_value=state["installed_version"],
994
+ installed_fp=installed_fp,
995
+ service_status=service_status,
996
+ )
997
+ client_states = read_mcp_client_states()
747
998
  return {
748
999
  "ok": True,
749
1000
  "schema_version": MCP_STATUS_SCHEMA_VERSION,
@@ -762,7 +1013,18 @@ def build_mcp_status(*, client: str = "") -> dict:
762
1013
  "active_runtime_version": read_version_for_path(active_runtime_root()),
763
1014
  "restart_required": bool(state["restart_required"]),
764
1015
  "reason": state["reason"],
765
- "client_action": state["client_action"],
1016
+ "client_action": readiness["client_action"],
1017
+ "reason_code": readiness["reason_code"],
1018
+ "global_ready": bool(readiness["global_ready"]),
1019
+ "client_ready": bool(readiness["client_ready"]),
1020
+ "runtime_generation": readiness["runtime_generation"],
1021
+ "client_state": readiness["client_state"],
1022
+ "last_seen_generation": readiness["client_state"].get("last_seen_generation", ""),
1023
+ "last_tool_count": readiness["client_state"].get("last_tool_count", 0),
1024
+ "last_probe_ok": readiness["client_state"].get("last_probe_ok", False),
1025
+ "last_fingerprint": readiness["client_state"].get("last_fingerprint", ""),
1026
+ "client_states": client_states.get("clients", {}),
1027
+ "client_state_path": client_states.get("path", str(mcp_client_state_path())),
766
1028
  "marker_path": marker.get("path", str(restart_required_marker_path())),
767
1029
  "marker_exists": bool(marker.get("exists")),
768
1030
  "marker_corrupt": bool(marker.get("corrupt")),
@@ -1886,8 +1886,7 @@ def retire_superseded_personal_scripts(*, dry_run: bool = False) -> dict:
1886
1886
  report["unscheduled"].append(removed)
1887
1887
 
1888
1888
  if backup_root is None:
1889
- backup_root = paths.backups_dir() / f"retired-personal-scripts-{time.strftime('%Y-%m-%d-%H%M%S')}"
1890
- backup_root.mkdir(parents=True, exist_ok=True)
1889
+ backup_root = paths.create_backup_dir("retired-personal-scripts")
1891
1890
  target = backup_root / path.name
1892
1891
  suffix = 2
1893
1892
  while target.exists():
@@ -1902,6 +1901,8 @@ def retire_superseded_personal_scripts(*, dry_run: bool = False) -> dict:
1902
1901
  })
1903
1902
  except Exception as exc:
1904
1903
  report["errors"].append({"path": str(path), "error": str(exc)})
1904
+ if backup_root is not None:
1905
+ paths.finalize_backup_snapshot(backup_root)
1905
1906
  return report
1906
1907
 
1907
1908
 
@@ -40,6 +40,12 @@ import sys
40
40
  import time
41
41
  from pathlib import Path
42
42
 
43
+ SOURCE_ROOT = Path(__file__).resolve().parents[1]
44
+ if str(SOURCE_ROOT) not in sys.path:
45
+ sys.path.insert(0, str(SOURCE_ROOT))
46
+
47
+ import paths
48
+
43
49
 
44
50
  DEFAULT_DB_PATH = Path.home() / ".nexo" / "runtime" / "data" / "nexo.db"
45
51
  DEFAULT_CALIBRATION = Path.home() / ".nexo" / "brain" / "calibration.json"
@@ -253,11 +259,11 @@ def _has_column(conn: sqlite3.Connection, table: str, col: str) -> bool:
253
259
 
254
260
 
255
261
  def _backup_db(db_path: Path) -> Path:
256
- ts = time.strftime("%Y-%m-%d-%H%M%S", time.gmtime())
257
- backup = db_path.parent.parent / "backups" / f"pre-backfill-owner-{ts}" / db_path.name
258
- backup.parent.mkdir(parents=True, exist_ok=True)
262
+ backup_dir = paths.create_backup_dir("pre-backfill-owner")
263
+ backup = backup_dir / db_path.name
259
264
  shutil.copy2(db_path, backup)
260
- _rotate_backup_family(backup.parent.parent)
265
+ _rotate_backup_family(backup_dir.parent)
266
+ paths.finalize_backup_snapshot(backup_dir)
261
267
  return backup
262
268
 
263
269
 
@@ -32,6 +32,7 @@ if str(NEXO_CODE) not in sys.path:
32
32
 
33
33
  import db as nexo_db
34
34
  import paths
35
+ from cognitive_paths import resolve_cognitive_db
35
36
 
36
37
  def _resolve_nexo_db() -> Path:
37
38
  candidates = [
@@ -53,11 +54,7 @@ def _resolve_nexo_db() -> Path:
53
54
 
54
55
 
55
56
  def _resolve_cognitive_db() -> Path:
56
- new = paths.data_dir() / "cognitive.db"
57
- legacy = paths.legacy_data_dir() / "cognitive.db"
58
- if not new.is_file() and legacy.is_file():
59
- return legacy
60
- return new
57
+ return resolve_cognitive_db(for_write=True)
61
58
 
62
59
 
63
60
  def _resolve_operations_dir() -> Path:
@@ -27,6 +27,7 @@ if str(NEXO_CODE) not in sys.path:
27
27
 
28
28
  import transcript_utils as _transcripts
29
29
  import paths
30
+ from cognitive_paths import resolve_cognitive_db
30
31
 
31
32
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
32
33
 
@@ -51,11 +52,7 @@ def _resolve_nexo_db() -> Path:
51
52
 
52
53
 
53
54
  def _resolve_cognitive_db() -> Path:
54
- new = paths.data_dir() / "cognitive.db"
55
- legacy = paths.legacy_data_dir() / "cognitive.db"
56
- if not new.is_file() and legacy.is_file():
57
- return legacy
58
- return new
55
+ return resolve_cognitive_db(for_write=False)
59
56
 
60
57
 
61
58
  def _resolve_deep_sleep_dir() -> Path:
@@ -37,6 +37,7 @@ _repo_src = _script_dir.parent # src/scripts/ -> src/
37
37
  NEXO_CODE = _bootstrap_nexo_code(_repo_src)
38
38
  from datetime import datetime, timedelta
39
39
 
40
+ from cognitive_paths import resolve_cognitive_db
40
41
  from paths import data_dir, operations_dir
41
42
  import cognitive
42
43
 
@@ -87,7 +88,7 @@ def _open_correction_fatigue_followup(fatigued: list) -> str:
87
88
  lines.append(f"... and {len(fatigued) - 10} more")
88
89
  description = "\n".join(lines)
89
90
  verification = (
90
- f"sqlite3 {str(data_dir() / 'cognitive.db')} \"SELECT id, content, strength, tags "
91
+ f"sqlite3 {str(resolve_cognitive_db(for_write=False))} \"SELECT id, content, strength, tags "
91
92
  "FROM ltm_memories WHERE tags LIKE '%under_review%' ORDER BY strength ASC LIMIT 50\""
92
93
  )
93
94
  now_epoch = datetime.now().timestamp()
@@ -73,6 +73,7 @@ from paths import (
73
73
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
74
74
  from constants import AUTOMATION_SUBPROCESS_TIMEOUT
75
75
  from core_prompts import render_core_prompt
76
+ from cognitive_paths import audit_cognitive_db_paths, resolve_cognitive_db
76
77
  import db as nexo_db
77
78
  from public_evolution_queue import queue_public_port_candidate
78
79
 
@@ -914,15 +915,34 @@ def check_evolution_health():
914
915
 
915
916
 
916
917
  def check_disk_space():
917
- result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True)
918
- for line in result.stdout.strip().split("\n")[1:]:
919
- parts = line.split()
920
- if len(parts) >= 5:
921
- usage_pct = int(parts[4].replace("%", ""))
922
- if usage_pct > 90:
923
- finding("ERROR", "disk", f"Root disk at {usage_pct}% capacity")
924
- elif usage_pct > 80:
925
- finding("WARN", "disk", f"Root disk at {usage_pct}% capacity")
918
+ import paths as paths_module
919
+
920
+ try:
921
+ floor = int(paths_module.backup_min_free_bytes())
922
+ usage_before = shutil.disk_usage(str(paths_module.home()))
923
+ if usage_before.free < floor:
924
+ paths_module.aggressive_runtime_backup_prune(
925
+ min_free_bytes=floor,
926
+ reason="daily_self_audit_disk_space",
927
+ )
928
+ usage = shutil.disk_usage(str(paths_module.home()))
929
+ if usage_before.free < floor <= usage.free:
930
+ script = NEXO_CODE / "scripts" / "post_disk_recovery_sweep.py"
931
+ if script.is_file():
932
+ subprocess.run(
933
+ [sys.executable, str(script), "--reason", "daily_self_audit_disk_low_to_ok"],
934
+ capture_output=True,
935
+ text=True,
936
+ timeout=60,
937
+ )
938
+ usage_pct = int(((usage.total - usage.free) / usage.total) * 100)
939
+ free_gb = usage.free / (1024 ** 3)
940
+ if usage.free < floor:
941
+ finding("ERROR", "disk", f"Root disk at {usage_pct}% capacity after NEXO self-cleanup ({free_gb:.1f} GB free)")
942
+ elif usage_pct > 80:
943
+ finding("WARN", "disk", f"Root disk at {usage_pct}% capacity ({free_gb:.1f} GB free)")
944
+ except Exception as exc:
945
+ finding("WARN", "disk", f"Could not check disk space: {exc}")
926
946
 
927
947
 
928
948
  def check_db_size():
@@ -1735,7 +1755,13 @@ def check_watchdog_smoke():
1735
1755
 
1736
1756
 
1737
1757
  def check_cognitive_health():
1738
- cognitive_db = data_dir() / "cognitive.db"
1758
+ path_audit = audit_cognitive_db_paths()
1759
+ if path_audit["status"] == "error":
1760
+ finding("ERROR", "cognitive-paths", path_audit["reason"])
1761
+ return
1762
+ if path_audit["status"] == "warning":
1763
+ finding("WARN", "cognitive-paths", path_audit["reason"])
1764
+ cognitive_db = resolve_cognitive_db(for_write=False)
1739
1765
  if not cognitive_db.exists():
1740
1766
  finding("WARN", "cognitive", "cognitive.db not found")
1741
1767
  return
@@ -301,7 +301,7 @@ def get_all_active_followups(state: dict) -> dict:
301
301
  rows = conn.execute(
302
302
  "SELECT id, description, date, reasoning, verification, priority, recurrence, status, owner, updated_at "
303
303
  "FROM followups WHERE status NOT LIKE 'COMPLETED%' "
304
- "AND UPPER(COALESCE(status, '')) NOT IN ('BLOCKED', 'ARCHIVED', 'DELETED', 'WAITING') "
304
+ "AND UPPER(COALESCE(status, '')) NOT IN ('BLOCKED', 'ARCHIVED', 'DELETED', 'WAITING', 'DONE') "
305
305
  "AND description NOT LIKE '[Abandoned]%' "
306
306
  "ORDER BY "
307
307
  " CASE priority "
@@ -40,6 +40,7 @@ if str(NEXO_CODE) not in sys.path:
40
40
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
41
41
  from constants import AUTOMATION_SUBPROCESS_TIMEOUT
42
42
  from core_prompts import render_core_prompt
43
+ from cognitive_paths import resolve_cognitive_db
43
44
  import paths
44
45
 
45
46
  from urllib.request import Request, urlopen
@@ -382,7 +383,7 @@ def check_databases():
382
383
 
383
384
  dbs = [
384
385
  ("nexo.db", paths.db_path()),
385
- ("cognitive.db", paths.data_dir() / "cognitive.db"),
386
+ ("cognitive.db", resolve_cognitive_db(for_write=False)),
386
387
  ]
387
388
  if CLAUDE_MEM_DB.exists():
388
389
  dbs.append(("claude-mem.db", CLAUDE_MEM_DB))
@@ -103,9 +103,7 @@ def get_target_version() -> str:
103
103
 
104
104
  def backup_databases() -> str:
105
105
  """Backup all .db files before migration. Returns backup dir path."""
106
- ts = datetime.now().strftime("%Y%m%d-%H%M%S")
107
- backup_dir = paths.backups_dir() / f"pre-migrate-{ts}"
108
- backup_dir.mkdir(parents=True, exist_ok=True)
106
+ backup_dir = paths.create_backup_dir("pre-migrate")
109
107
 
110
108
  data_dir = paths.data_dir()
111
109
  if data_dir.exists():
@@ -123,6 +121,7 @@ def backup_databases() -> str:
123
121
  if vfile.exists():
124
122
  shutil.copy2(vfile, backup_dir / "version.json")
125
123
 
124
+ paths.finalize_backup_snapshot(backup_dir)
126
125
  return str(backup_dir)
127
126
 
128
127