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
@@ -83,6 +83,15 @@ class CronSpoolClassification:
83
83
  reason: str
84
84
 
85
85
 
86
+ @dataclass(frozen=True)
87
+ class EvolutionPolicyClassification:
88
+ status: str
89
+ severity: str
90
+ reason: str
91
+ launchagent_label: str = "com.nexo.evolution"
92
+ desktop_managed: bool = False
93
+
94
+
86
95
  def default_config() -> AutomationSupervisorConfig:
87
96
  home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
88
97
  if paths is not None:
@@ -120,7 +129,8 @@ def audit_automation(config: AutomationSupervisorConfig | None = None) -> dict[s
120
129
  now=now,
121
130
  warn_threshold=cfg.spool_warn_threshold,
122
131
  )
123
- findings = _collect_findings(open_runs, launchagents, cron_spool)
132
+ evolution = classify_evolution_policy(cfg.manifest_path, cfg.launchagent_labels)
133
+ findings = _collect_findings(open_runs, launchagents, cron_spool, evolution)
124
134
 
125
135
  return {
126
136
  "ok": not any(item.get("severity") in TERMINAL_SEVERITIES for item in findings),
@@ -129,6 +139,7 @@ def audit_automation(config: AutomationSupervisorConfig | None = None) -> dict[s
129
139
  "open_runs": [asdict(item) for item in open_runs],
130
140
  "launchagents": [asdict(item) for item in launchagents],
131
141
  "cron_spool": [asdict(item) for item in cron_spool],
142
+ "evolution": asdict(evolution),
132
143
  "findings": findings,
133
144
  "summary": {
134
145
  "jobs": len(contracts),
@@ -140,6 +151,7 @@ def audit_automation(config: AutomationSupervisorConfig | None = None) -> dict[s
140
151
  "p1": sum(1 for item in findings if item.get("severity") == "P1"),
141
152
  "p2": sum(1 for item in findings if item.get("severity") == "P2"),
142
153
  "excluded_jobs": sorted(excluded),
154
+ "evolution_status": evolution.status,
143
155
  },
144
156
  }
145
157
 
@@ -369,21 +381,82 @@ def classify_cron_spool(
369
381
  return sorted(results, key=lambda item: (item.severity != "P1", item.cron_id))
370
382
 
371
383
 
384
+ def classify_evolution_policy(
385
+ manifest_path: Path | None,
386
+ launchagent_labels: frozenset[str] | set[str] | list[str] | tuple[str, ...] | None,
387
+ ) -> EvolutionPolicyClassification:
388
+ try:
389
+ from product_mode import DESKTOP_EVOLUTION_DISABLED_REASON, desktop_product_requested
390
+
391
+ desktop_managed = bool(desktop_product_requested())
392
+ disabled_reason = DESKTOP_EVOLUTION_DISABLED_REASON
393
+ except Exception:
394
+ desktop_managed = False
395
+ disabled_reason = "Disabled by product policy"
396
+
397
+ if desktop_managed:
398
+ return EvolutionPolicyClassification(
399
+ status="disabled_by_policy",
400
+ severity="OK",
401
+ reason=disabled_reason,
402
+ desktop_managed=True,
403
+ )
404
+
405
+ manifest = _load_json(manifest_path, default={"crons": []})
406
+ entries = manifest.get("crons") if isinstance(manifest, Mapping) else []
407
+ evolution_entry = None
408
+ if isinstance(entries, list):
409
+ for entry in entries:
410
+ if isinstance(entry, Mapping) and _is_evolution(str(entry.get("id") or "")):
411
+ evolution_entry = entry
412
+ break
413
+ if not evolution_entry:
414
+ return EvolutionPolicyClassification(
415
+ status="unknown",
416
+ severity="P2",
417
+ reason="Brain standalone mode has no Evolution cron entry in the manifest",
418
+ )
419
+ label = str(evolution_entry.get("launchagent_label") or "com.nexo.evolution")
420
+ if launchagent_labels is None:
421
+ return EvolutionPolicyClassification(
422
+ status="unknown",
423
+ severity="P2",
424
+ reason="Brain standalone Evolution is declared, but LaunchAgent inventory was not supplied",
425
+ launchagent_label=label,
426
+ )
427
+ labels = {str(item) for item in launchagent_labels}
428
+ if label in labels:
429
+ return EvolutionPolicyClassification(
430
+ status="enabled_and_loaded",
431
+ severity="OK",
432
+ reason="Brain standalone Evolution is declared and loaded in the supplied inventory",
433
+ launchagent_label=label,
434
+ )
435
+ return EvolutionPolicyClassification(
436
+ status="enabled_but_not_loaded",
437
+ severity="P1",
438
+ reason="Brain standalone Evolution is declared but absent from the supplied inventory",
439
+ launchagent_label=label,
440
+ )
441
+
442
+
372
443
  def format_markdown(report: Mapping[str, Any]) -> str:
373
444
  summary = report.get("summary") if isinstance(report, Mapping) else {}
374
445
  findings = report.get("findings") if isinstance(report, Mapping) else []
446
+ evolution = report.get("evolution") if isinstance(report.get("evolution"), Mapping) else {}
375
447
  lines = [
376
- "### G13 Automation supervisor sin Evolution",
448
+ "### Automation supervisor",
377
449
  "",
378
- "| Area | Resultado |",
450
+ "| Area | Result |",
379
451
  "|---|---|",
380
452
  f"| Jobs no Evolution | {summary.get('jobs', 0)} |",
381
- f"| Open runs clasificadas | {summary.get('open_runs', 0)} |",
382
- f"| Cron-spool jobs con JSON | {summary.get('cron_spool_jobs', 0)} |",
383
- f"| Hallazgos P1 | {summary.get('p1', 0)} |",
384
- f"| Evolution excluido | {', '.join(summary.get('excluded_jobs') or []) or 'si'} |",
453
+ f"| Classified open runs | {summary.get('open_runs', 0)} |",
454
+ f"| Cron-spool jobs with JSON | {summary.get('cron_spool_jobs', 0)} |",
455
+ f"| P1 findings | {summary.get('p1', 0)} |",
456
+ f"| Evolution excluded from cron reconciliation | {', '.join(summary.get('excluded_jobs') or []) or 'yes'} |",
457
+ f"| Evolution policy | {evolution.get('status', 'unknown')} |",
385
458
  ]
386
- lines.extend(["", "| Hallazgo | Severidad | Razon |", "|---|---|---|"])
459
+ lines.extend(["", "| Finding | Severity | Reason |", "|---|---|---|"])
387
460
  for item in findings or []:
388
461
  lines.append(
389
462
  "| {kind}:{key} | {severity} | {reason} |".format(
@@ -402,6 +475,7 @@ def _collect_findings(
402
475
  open_runs: Iterable[OpenRunClassification],
403
476
  launchagents: Iterable[LaunchAgentClassification],
404
477
  cron_spool: Iterable[CronSpoolClassification],
478
+ evolution: EvolutionPolicyClassification | None = None,
405
479
  ) -> list[dict[str, Any]]:
406
480
  findings: list[dict[str, Any]] = []
407
481
  for item in open_runs:
@@ -413,6 +487,8 @@ def _collect_findings(
413
487
  for item in cron_spool:
414
488
  if item.severity != "OK":
415
489
  findings.append({"kind": "cron_spool", "key": item.cron_id, **asdict(item)})
490
+ if evolution is not None and evolution.severity != "OK":
491
+ findings.append({"kind": "evolution", "key": evolution.launchagent_label, **asdict(evolution)})
416
492
  severity_order = {"P0": 0, "P1": 1, "P2": 2, "OK": 3}
417
493
  return sorted(findings, key=lambda item: (severity_order.get(str(item.get("severity")), 9), str(item.get("kind")), str(item.get("key"))))
418
494
 
@@ -422,7 +498,8 @@ def _load_open_cron_rows(db_path: Path | None) -> list[dict[str, Any]]:
422
498
  return []
423
499
  conn = None
424
500
  try:
425
- conn = sqlite3.connect(str(db_path), timeout=2)
501
+ uri = db_path.resolve().as_uri() + "?mode=ro"
502
+ conn = sqlite3.connect(uri, timeout=2, uri=True)
426
503
  conn.row_factory = sqlite3.Row
427
504
  table = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='cron_runs'").fetchone()
428
505
  if table is None:
@@ -0,0 +1,70 @@
1
+ """Machine-readable backup retention contracts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import paths
10
+
11
+
12
+ def _pruner_script() -> Path:
13
+ script = Path(__file__).resolve().parent / "scripts" / "prune_runtime_backups.py"
14
+ if script.is_file():
15
+ return script
16
+ fallback = paths.core_scripts_dir() / "prune_runtime_backups.py"
17
+ if fallback.is_file():
18
+ return fallback
19
+ raise FileNotFoundError("prune_runtime_backups.py not found")
20
+
21
+
22
+ def _run_pruner(
23
+ *,
24
+ root: Path | None = None,
25
+ apply: bool = False,
26
+ max_bytes: str | int | None = None,
27
+ delete_all_technical: bool = False,
28
+ ) -> dict:
29
+ command = [
30
+ sys.executable,
31
+ str(_pruner_script()),
32
+ "--root",
33
+ str(root or paths.backups_dir()),
34
+ "--json",
35
+ ]
36
+ if apply:
37
+ command.append("--apply")
38
+ if max_bytes is not None:
39
+ command.extend(["--max-bytes", str(max_bytes)])
40
+ if delete_all_technical:
41
+ command.append("--delete-all-technical")
42
+ proc = subprocess.run(command, capture_output=True, text=True, timeout=120)
43
+ try:
44
+ payload = json.loads(proc.stdout or "{}")
45
+ except json.JSONDecodeError:
46
+ payload = {"raw_stdout": proc.stdout}
47
+ payload["ok"] = proc.returncode == 0
48
+ payload["returncode"] = proc.returncode
49
+ payload["stderr"] = proc.stderr[-2000:]
50
+ return payload
51
+
52
+
53
+ def backup_retention_plan(*, root: Path | None = None, max_bytes: str | int | None = None) -> dict:
54
+ """Return a deterministic dry-run retention plan."""
55
+ return _run_pruner(root=root, max_bytes=max_bytes, apply=False)
56
+
57
+
58
+ def backup_retention_apply(
59
+ *,
60
+ root: Path | None = None,
61
+ max_bytes: str | int | None = None,
62
+ delete_all_technical: bool = False,
63
+ ) -> dict:
64
+ """Apply backup retention with the pruner's restore-point guard enabled."""
65
+ return _run_pruner(
66
+ root=root,
67
+ max_bytes=max_bytes,
68
+ apply=True,
69
+ delete_all_technical=delete_all_technical,
70
+ )
package/src/cli.py CHANGED
@@ -52,8 +52,23 @@ import sys
52
52
  import time
53
53
  from pathlib import Path
54
54
 
55
+ try:
56
+ from cognitive_paths import resolve_cognitive_db
57
+ except ModuleNotFoundError as exc:
58
+ if getattr(exc, "name", "") != "cognitive_paths":
59
+ raise
60
+
61
+ def resolve_cognitive_db(*, for_write: bool = True, **_kwargs) -> Path:
62
+ """Fallback for older installed runtimes before update copies cognitive_paths.py."""
63
+ override = os.environ.get("NEXO_COGNITIVE_DB", "").strip()
64
+ if override:
65
+ return Path(override).expanduser()
66
+ target = paths.runtime_dir() / "cognitive" / "cognitive.db"
67
+ if for_write:
68
+ target.parent.mkdir(parents=True, exist_ok=True)
69
+ return target
55
70
  from runtime_home import export_resolved_nexo_home
56
- from runtime_versioning import build_mcp_status, clear_restart_required_marker
71
+ from runtime_versioning import build_mcp_status, clear_restart_required_marker, record_mcp_client_probe
57
72
  try:
58
73
  from mcp_required_tools import BOOTSTRAP_REQUIRED_MCP_TOOLS, missing_required_tools
59
74
  except ModuleNotFoundError as exc:
@@ -290,6 +305,12 @@ def _mcp_probe(args) -> int:
290
305
  "elapsed_ms": int((time.monotonic() - started_at) * 1000),
291
306
  "stderr_tail": "\n".join(stderr_lines[-12:]),
292
307
  }
308
+ recorded = record_mcp_client_probe(client=client, probe=payload)
309
+ if recorded.get("ok"):
310
+ payload["client_ready"] = bool(recorded.get("last_probe_ok"))
311
+ payload["client_action"] = recorded.get("client_action", "ready")
312
+ payload["reason_code"] = recorded.get("reason_code", "ready")
313
+ payload["runtime_generation"] = recorded.get("last_seen_generation", "")
293
314
  return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
294
315
  except Exception as exc:
295
316
  payload = {
@@ -302,6 +323,12 @@ def _mcp_probe(args) -> int:
302
323
  "elapsed_ms": int((time.monotonic() - started_at) * 1000),
303
324
  "stderr_tail": "\n".join(stderr_lines[-20:]),
304
325
  }
326
+ recorded = record_mcp_client_probe(client=client, probe=payload)
327
+ if recorded.get("ok"):
328
+ payload["client_ready"] = False
329
+ payload["client_action"] = recorded.get("client_action", "reprobe")
330
+ payload["reason_code"] = recorded.get("reason_code", "mcp_probe_failed")
331
+ payload["runtime_generation"] = recorded.get("last_seen_generation", "")
305
332
  return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
306
333
  finally:
307
334
  if proc is not None:
@@ -324,6 +351,8 @@ def _mcp_clear_restart(args) -> int:
324
351
  client=getattr(args, "client", "") or "",
325
352
  installed_version=getattr(args, "installed_version", "") or "",
326
353
  process_version=getattr(args, "process_version", "") or "",
354
+ installed_fingerprint=getattr(args, "installed_fingerprint", "") or "",
355
+ process_fingerprint=getattr(args, "process_fingerprint", "") or "",
327
356
  ),
328
357
  as_json=bool(getattr(args, "json", False)),
329
358
  )
@@ -950,6 +979,19 @@ def _automations_status(args):
950
979
  return 0
951
980
 
952
981
 
982
+ def _automations_reconcile(args):
983
+ from automation_reconciler import apply_reconciliation_plan, build_reconciliation_plan
984
+
985
+ plan = build_reconciliation_plan()
986
+ if bool(getattr(args, "apply", False)):
987
+ result = apply_reconciliation_plan(plan)
988
+ payload = {"ok": result.get("ok", False), "plan": plan, "apply": result}
989
+ else:
990
+ payload = plan
991
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
992
+ return 0 if payload.get("ok", True) else 1
993
+
994
+
953
995
  def _automations_set_instructions(args):
954
996
  from script_registry import set_automation_instructions
955
997
 
@@ -1136,7 +1178,7 @@ def _scripts_run(args):
1136
1178
  # Only inject DB paths for core scripts
1137
1179
  if is_core:
1138
1180
  env["NEXO_DB"] = str(paths.db_path())
1139
- env["NEXO_COGNITIVE_DB"] = str(paths.data_dir() / "cognitive.db")
1181
+ env["NEXO_COGNITIVE_DB"] = str(resolve_cognitive_db(for_write=True))
1140
1182
 
1141
1183
  # Timeout
1142
1184
  timeout = None
@@ -3617,6 +3659,13 @@ def main():
3617
3659
  automations_status_p.add_argument("name", help="Automation name or path")
3618
3660
  automations_status_p.add_argument("--json", action="store_true", help="JSON output")
3619
3661
 
3662
+ automations_reconcile_p = automations_sub.add_parser(
3663
+ "reconcile",
3664
+ help="Build or apply the safe automation reconciliation plan",
3665
+ )
3666
+ automations_reconcile_p.add_argument("--apply", action="store_true", help="Apply only safe deterministic actions")
3667
+ automations_reconcile_p.add_argument("--json", action="store_true", help="JSON output")
3668
+
3620
3669
  automations_instructions_p = automations_sub.add_parser(
3621
3670
  "instructions",
3622
3671
  help="Set or clear operator extra instructions for an automation",
@@ -3891,6 +3940,8 @@ def main():
3891
3940
  mcp_clear_p.add_argument("--client", default="", help="Client label such as claude_desktop or codex")
3892
3941
  mcp_clear_p.add_argument("--installed-version", default="")
3893
3942
  mcp_clear_p.add_argument("--process-version", default="")
3943
+ mcp_clear_p.add_argument("--installed-fingerprint", default="")
3944
+ mcp_clear_p.add_argument("--process-fingerprint", default="")
3894
3945
  mcp_clear_p.add_argument("--json", action="store_true", help="JSON output")
3895
3946
 
3896
3947
  continuity_parser = sub.add_parser("continuity", help="Continuity snapshots and resume bundles")
@@ -4161,6 +4212,8 @@ def main():
4161
4212
  return _automations_reactivate(args)
4162
4213
  elif args.automations_command == "status":
4163
4214
  return _automations_status(args)
4215
+ elif args.automations_command == "reconcile":
4216
+ return _automations_reconcile(args)
4164
4217
  elif args.automations_command == "instructions":
4165
4218
  return _automations_set_instructions(args)
4166
4219
  elif args.automations_command == "schedule":
@@ -13,13 +13,14 @@ from datetime import datetime, timedelta
13
13
  from pathlib import Path
14
14
  from typing import Optional
15
15
 
16
- import paths
16
+ from cognitive_paths import resolve_cognitive_db
17
17
 
18
18
  NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
19
- _cognitive_dir = paths.cognitive_dir()
19
+ _cognitive_db_path = resolve_cognitive_db(for_write=True)
20
+ _cognitive_dir = _cognitive_db_path.parent
20
21
  _cognitive_dir.mkdir(parents=True, exist_ok=True)
21
22
 
22
- COGNITIVE_DB = str(_cognitive_dir / "cognitive.db")
23
+ COGNITIVE_DB = str(_cognitive_db_path)
23
24
  def _configured_embedding_dim() -> int:
24
25
  try:
25
26
  from local_models import get_local_model_spec
@@ -0,0 +1,194 @@
1
+ """Canonical cognitive.db path resolution and legacy shadow-DB guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sqlite3
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import paths
15
+
16
+
17
+ class CognitiveDbPathConflict(RuntimeError):
18
+ """Raised when canonical and legacy cognitive DBs could both receive writes."""
19
+
20
+
21
+ def _configured_override() -> Path | None:
22
+ value = os.environ.get("NEXO_COGNITIVE_DB", "").strip()
23
+ return Path(value).expanduser() if value else None
24
+
25
+
26
+ def canonical_cognitive_dir() -> Path:
27
+ return paths.runtime_dir() / "cognitive"
28
+
29
+
30
+ def canonical_cognitive_db_path() -> Path:
31
+ override = _configured_override()
32
+ if override is not None:
33
+ return override
34
+ return canonical_cognitive_dir() / "cognitive.db"
35
+
36
+
37
+ def legacy_cognitive_db_paths() -> list[Path]:
38
+ canonical = canonical_cognitive_db_path()
39
+ candidates = [
40
+ paths.runtime_dir() / "data" / "cognitive.db",
41
+ paths.legacy_data_dir() / "cognitive.db",
42
+ paths.home() / "cognitive" / "cognitive.db",
43
+ ]
44
+ unique: list[Path] = []
45
+ seen: set[str] = set()
46
+ for candidate in candidates:
47
+ try:
48
+ key = str(candidate.resolve())
49
+ canonical_key = str(canonical.resolve())
50
+ except Exception:
51
+ key = str(candidate)
52
+ canonical_key = str(canonical)
53
+ if key == canonical_key or key in seen:
54
+ continue
55
+ seen.add(key)
56
+ unique.append(candidate)
57
+ return unique
58
+
59
+
60
+ def _sha256(path: Path) -> str:
61
+ digest = hashlib.sha256()
62
+ with path.open("rb") as handle:
63
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
64
+ digest.update(chunk)
65
+ return digest.hexdigest()
66
+
67
+
68
+ def _sqlite_signature(path: Path) -> dict[str, Any]:
69
+ if not path.exists():
70
+ return {"exists": False}
71
+ signature: dict[str, Any] = {
72
+ "exists": True,
73
+ "path": str(path),
74
+ "size_bytes": path.stat().st_size,
75
+ "sha256": _sha256(path),
76
+ }
77
+ try:
78
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
79
+ rows = conn.execute(
80
+ "SELECT type, name, sql FROM sqlite_master "
81
+ "WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name"
82
+ ).fetchall()
83
+ user_version = conn.execute("PRAGMA user_version").fetchone()[0]
84
+ conn.close()
85
+ schema_blob = json.dumps(rows, sort_keys=True, default=str)
86
+ signature.update({
87
+ "sqlite_ok": True,
88
+ "user_version": int(user_version or 0),
89
+ "schema_sha256": hashlib.sha256(schema_blob.encode("utf-8")).hexdigest(),
90
+ "tables": [row[1] for row in rows if row[0] == "table"],
91
+ })
92
+ except Exception as exc:
93
+ signature.update({
94
+ "sqlite_ok": False,
95
+ "sqlite_error": str(exc)[:240],
96
+ })
97
+ return signature
98
+
99
+
100
+ def _migration_marker_path() -> Path:
101
+ return paths.runtime_state_dir() / "cognitive-db-migration.json"
102
+
103
+
104
+ def _write_migration_marker(source: Path, target: Path) -> None:
105
+ marker = {
106
+ "at": datetime.now(timezone.utc).isoformat(),
107
+ "source": str(source),
108
+ "target": str(target),
109
+ "source_sha256": _sha256(source) if source.exists() else "",
110
+ "target_sha256": _sha256(target) if target.exists() else "",
111
+ "legacy_retained": True,
112
+ }
113
+ marker_path = _migration_marker_path()
114
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
115
+ marker_path.write_text(json.dumps(marker, indent=2, sort_keys=True) + "\n", encoding="utf-8")
116
+
117
+
118
+ def audit_cognitive_db_paths() -> dict[str, Any]:
119
+ canonical = canonical_cognitive_db_path()
120
+ canonical_sig = _sqlite_signature(canonical)
121
+ legacy = [
122
+ {
123
+ "path": str(candidate),
124
+ "signature": _sqlite_signature(candidate),
125
+ }
126
+ for candidate in legacy_cognitive_db_paths()
127
+ ]
128
+ existing_legacy = [entry for entry in legacy if entry["signature"].get("exists")]
129
+ divergent = [
130
+ entry for entry in existing_legacy
131
+ if canonical_sig.get("exists")
132
+ and entry["signature"].get("sha256")
133
+ and entry["signature"].get("sha256") != canonical_sig.get("sha256")
134
+ ]
135
+ if divergent:
136
+ status = "error"
137
+ reason = "canonical_and_legacy_diverge"
138
+ elif not canonical_sig.get("exists") and existing_legacy:
139
+ status = "warning"
140
+ reason = "legacy_only"
141
+ elif existing_legacy:
142
+ status = "ok"
143
+ reason = "legacy_duplicate_retained"
144
+ else:
145
+ status = "ok"
146
+ reason = "canonical_only"
147
+ return {
148
+ "status": status,
149
+ "reason": reason,
150
+ "canonical": {"path": str(canonical), "signature": canonical_sig},
151
+ "legacy": legacy,
152
+ "migration_marker": str(_migration_marker_path()),
153
+ }
154
+
155
+
156
+ def _first_existing_legacy() -> Path | None:
157
+ for candidate in legacy_cognitive_db_paths():
158
+ if candidate.is_file():
159
+ return candidate
160
+ return None
161
+
162
+
163
+ def migrate_legacy_cognitive_db_if_needed() -> dict[str, Any]:
164
+ override = _configured_override()
165
+ if override is not None:
166
+ return {"migrated": False, "reason": "env_override", "path": str(override)}
167
+ canonical = canonical_cognitive_db_path()
168
+ if canonical.exists():
169
+ return {"migrated": False, "reason": "canonical_exists", "path": str(canonical)}
170
+ source = _first_existing_legacy()
171
+ if source is None:
172
+ return {"migrated": False, "reason": "no_legacy", "path": str(canonical)}
173
+ canonical.parent.mkdir(parents=True, exist_ok=True)
174
+ shutil.copy2(source, canonical)
175
+ _write_migration_marker(source, canonical)
176
+ return {"migrated": True, "reason": "legacy_copied", "source": str(source), "path": str(canonical)}
177
+
178
+
179
+ def resolve_cognitive_db(*, for_write: bool = True, migrate: bool = True, create_parent: bool = True) -> Path:
180
+ """Return the cognitive DB path; block writes when legacy shadows diverge."""
181
+ target = canonical_cognitive_db_path()
182
+ if create_parent:
183
+ target.parent.mkdir(parents=True, exist_ok=True)
184
+ if migrate:
185
+ migrate_legacy_cognitive_db_if_needed()
186
+ audit = audit_cognitive_db_paths()
187
+ if for_write and audit["status"] == "error":
188
+ raise CognitiveDbPathConflict(
189
+ "Refusing to write cognitive.db while canonical and legacy databases diverge. "
190
+ f"Canonical: {audit['canonical']['path']}; legacy: "
191
+ + ", ".join(entry["path"] for entry in audit["legacy"] if entry["signature"].get("exists"))
192
+ )
193
+ return target
194
+
@@ -30,6 +30,7 @@ if _PARENT not in sys.path:
30
30
  sys.path.insert(0, _PARENT)
31
31
 
32
32
  from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
33
+ from cognitive_paths import resolve_cognitive_db
33
34
  import paths
34
35
 
35
36
  TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
@@ -200,7 +201,7 @@ class ChatMessage(BaseModel):
200
201
 
201
202
  def _cognitive_db():
202
203
  """Direct connection to cognitive.db."""
203
- db_path = Path(os.environ.get("NEXO_COGNITIVE_DB") or str(paths.data_dir() / "cognitive.db"))
204
+ db_path = resolve_cognitive_db(for_write=True)
204
205
  conn = sqlite3.connect(str(db_path))
205
206
  conn.row_factory = sqlite3.Row
206
207
  return conn