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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +7 -1
- package/package.json +1 -1
- package/scripts/sync_release_artifacts.py +28 -0
- package/src/auto_update.py +25 -47
- package/src/automation_reconciler.py +383 -0
- package/src/automation_supervisor.py +86 -9
- package/src/backup_retention.py +70 -0
- package/src/cli.py +55 -2
- package/src/cognitive/_core.py +4 -3
- package/src/cognitive_paths.py +194 -0
- package/src/dashboard/app.py +2 -1
- package/src/db/_episodic.py +85 -7
- package/src/db/_schema.py +81 -0
- package/src/db/_skills.py +3 -3
- package/src/disk_recovery/__init__.py +11 -0
- package/src/disk_recovery/handlers/__init__.py +1 -0
- package/src/disk_recovery/handlers/common.py +37 -0
- package/src/disk_recovery/handlers/macos.py +39 -0
- package/src/disk_recovery/handlers/windows.py +49 -0
- package/src/disk_recovery/registry.py +135 -0
- package/src/doctor/providers/boot.py +115 -15
- package/src/kg_populate.py +2 -5
- package/src/paths.py +321 -5
- package/src/plugins/update.py +14 -36
- package/src/pre_answer_router.py +21 -0
- package/src/runtime_service.py +30 -3
- package/src/runtime_versioning.py +272 -10
- package/src/script_registry.py +3 -2
- package/src/scripts/backfill_task_owner.py +10 -4
- package/src/scripts/deep-sleep/apply_findings.py +2 -5
- package/src/scripts/deep-sleep/collect.py +2 -5
- package/src/scripts/nexo-cognitive-decay.py +2 -1
- package/src/scripts/nexo-daily-self-audit.py +36 -10
- package/src/scripts/nexo-followup-runner.py +1 -1
- package/src/scripts/nexo-immune.py +2 -1
- package/src/scripts/nexo-migrate.py +2 -3
- package/src/scripts/post_disk_recovery_sweep.py +75 -0
- package/src/scripts/prune_runtime_backups.py +78 -11
- package/src/server.py +13 -1
- package/src/storage_router.py +2 -3
- package/src/support_snapshot.py +25 -0
- package/src/transcript_index.py +234 -0
- package/src/transcript_utils.py +31 -8
- package/src/user_data_portability.py +2 -3
- 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
|
-
|
|
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
|
-
"###
|
|
448
|
+
"### Automation supervisor",
|
|
377
449
|
"",
|
|
378
|
-
"| Area |
|
|
450
|
+
"| Area | Result |",
|
|
379
451
|
"|---|---|",
|
|
380
452
|
f"| Jobs no Evolution | {summary.get('jobs', 0)} |",
|
|
381
|
-
f"|
|
|
382
|
-
f"| Cron-spool jobs
|
|
383
|
-
f"|
|
|
384
|
-
f"| Evolution
|
|
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(["", "|
|
|
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
|
-
|
|
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(
|
|
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":
|
package/src/cognitive/_core.py
CHANGED
|
@@ -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
|
|
16
|
+
from cognitive_paths import resolve_cognitive_db
|
|
17
17
|
|
|
18
18
|
NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
|
|
19
|
-
|
|
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(
|
|
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
|
+
|
package/src/dashboard/app.py
CHANGED
|
@@ -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 =
|
|
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
|