nexo-brain 7.9.13 → 7.9.14

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/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.9.13` is the current packaged-runtime line. Patch release over `7.9.12`: the remaining lifecycle/enforcement system prompts are now centralized in the core prompt catalog, operator-facing guard/followup/diary outputs are forced back into the operator language even when the base prompt is English, packaged installs recover the missing Guardian default preset, and the startup Guardian Health briefing now queries `hook_runs` correctly instead of reporting false green states. Coordinated Desktop release: v0.28.14.
21
+ Version `7.9.14` is the current packaged-runtime line. Patch release over `7.9.13`: `task_close(done)` now hard-blocks missing verify/change-log/cortex evidence instead of silently degrading to debt-only closes, self-audit auto-drains stale `protocol_debt` every day, and Codex session parity now flags partial bootstrap/startup/heartbeat drift instead of passing as healthy when only one recent session behaved correctly. Coordinated Desktop release remains v0.28.14.
22
22
 
23
23
  Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.13",
3
+ "version": "7.9.14",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -2112,14 +2112,32 @@ def check_codex_session_parity() -> DoctorCheck:
2112
2112
  status = "healthy"
2113
2113
  severity = "info"
2114
2114
  repair_plan: list[str] = []
2115
- if audit["bootstrap_sessions"] == 0:
2115
+ missing_bootstrap = max(0, audit["files"] - audit["bootstrap_sessions"])
2116
+ missing_startup = max(0, audit["files"] - audit["startup_sessions"])
2117
+ missing_heartbeat = max(0, audit["files"] - audit["heartbeat_sessions"])
2118
+ if missing_bootstrap:
2116
2119
  status = "degraded"
2117
2120
  severity = "warn"
2118
- repair_plan.append("Run `nexo update` or `nexo clients sync` so plain Codex sessions inherit the managed bootstrap")
2119
- if audit["startup_sessions"] == 0:
2121
+ repair_plan.append(
2122
+ "Run `nexo update` or `nexo clients sync` so every Codex session inherits the managed bootstrap, not just a subset"
2123
+ )
2124
+ if missing_startup:
2125
+ status = "degraded"
2126
+ severity = "warn"
2127
+ repair_plan.append(
2128
+ "Use `nexo chat` or keep the global Codex bootstrap intact so every Codex session actually calls `nexo_startup`"
2129
+ )
2130
+ if missing_heartbeat:
2120
2131
  status = "degraded"
2121
2132
  severity = "warn"
2122
- repair_plan.append("Use `nexo chat` or keep the global Codex bootstrap intact so sessions actually call `nexo_startup`")
2133
+ repair_plan.append("Keep `nexo_heartbeat` on every user turn so restored/plain Codex sessions do not drift off-protocol")
2134
+ if missing_bootstrap or missing_startup or missing_heartbeat:
2135
+ evidence.append(
2136
+ "session drift: "
2137
+ f"{missing_bootstrap} missing bootstrap, "
2138
+ f"{missing_startup} missing startup, "
2139
+ f"{missing_heartbeat} missing heartbeat"
2140
+ )
2123
2141
 
2124
2142
  return DoctorCheck(
2125
2143
  id="runtime.codex_sessions",
@@ -898,6 +898,46 @@ def _auto_capture_learning(task: dict, task_id: str, effective_files: list[str],
898
898
  )
899
899
 
900
900
 
901
+ def _append_debt_ref(debts: list[dict], debt: dict, *, debt_type: str, severity: str):
902
+ debt_id = debt.get("id")
903
+ if debt_id and any(item.get("id") == debt_id for item in debts):
904
+ return
905
+ debts.append(
906
+ {
907
+ "id": debt_id,
908
+ "debt_type": debt_type,
909
+ "severity": severity,
910
+ }
911
+ )
912
+
913
+
914
+ def _ensure_open_debt(
915
+ session_id: str,
916
+ task_id: str,
917
+ debt_type: str,
918
+ *,
919
+ severity: str,
920
+ evidence: str,
921
+ debts: list[dict],
922
+ ) -> dict:
923
+ existing = list_protocol_debts(
924
+ status="open",
925
+ task_id=task_id,
926
+ session_id="" if task_id else session_id,
927
+ debt_type=debt_type,
928
+ limit=1,
929
+ )
930
+ debt = existing[0] if existing else create_protocol_debt(
931
+ session_id,
932
+ debt_type,
933
+ severity=severity,
934
+ task_id=task_id,
935
+ evidence=evidence,
936
+ )
937
+ _append_debt_ref(debts, debt, debt_type=debt_type, severity=severity)
938
+ return debt
939
+
940
+
901
941
  def _record_debt(session_id: str, task_id: str, debt_type: str, *, severity: str, evidence: str, debts: list[dict]):
902
942
  debt = create_protocol_debt(
903
943
  session_id,
@@ -906,13 +946,7 @@ def _record_debt(session_id: str, task_id: str, debt_type: str, *, severity: str
906
946
  task_id=task_id,
907
947
  evidence=evidence,
908
948
  )
909
- debts.append(
910
- {
911
- "id": debt.get("id"),
912
- "debt_type": debt_type,
913
- "severity": severity,
914
- }
915
- )
949
+ _append_debt_ref(debts, debt, debt_type=debt_type, severity=severity)
916
950
 
917
951
 
918
952
  def handle_confidence_check(
@@ -1336,10 +1370,10 @@ def handle_task_close(
1336
1370
  high_stakes=bool(task.get("response_high_stakes")),
1337
1371
  )
1338
1372
 
1339
- # ── Evidence enforcement: reject 'done' without proof in strict mode ──
1340
- # Fase 2 R03 extension: "evidence" must not be empty, nor <50 chars, nor
1341
- # a single filler word like "done" / "listo" / "ok". Trivial evidence is
1342
- # rejected in strict mode and logged as protocol debt in any other mode.
1373
+ # ── Evidence enforcement: reject 'done' without proof ──
1374
+ # G1 hardening: "done" is no longer allowed to degrade into a debt-only
1375
+ # close when verify evidence is missing. Keep the task open, open/dedupe
1376
+ # the debt, and force the caller to provide real proof before closing.
1343
1377
  if task.get("must_verify") and clean_outcome == "done":
1344
1378
  is_trivial, trivial_reason = _is_trivial_evidence(clean_evidence)
1345
1379
  if not is_trivial:
@@ -1349,39 +1383,7 @@ def handle_task_close(
1349
1383
  resolution="Verification evidence supplied during task_close",
1350
1384
  )
1351
1385
  else:
1352
- protocol_strictness = get_protocol_strictness()
1353
- if protocol_strictness == "strict":
1354
- if trivial_reason == "empty":
1355
- err = "Cannot close task as 'done' without evidence."
1356
- hint = (
1357
- "Provide the `evidence` parameter with verifiable proof: "
1358
- "test output, curl response, screenshot path, or real "
1359
- "command output."
1360
- )
1361
- else:
1362
- err = (
1363
- "Cannot close task as 'done' with trivial evidence "
1364
- f"({trivial_reason})."
1365
- )
1366
- hint = (
1367
- f"Evidence must be substantive: >= {R03_MIN_EVIDENCE_CHARS} "
1368
- "characters AND not a single filler word. Attach real "
1369
- "proof — test output excerpt, curl response, DB row, "
1370
- "screenshot path, or command stdout."
1371
- )
1372
- return json.dumps(
1373
- {
1374
- "ok": False,
1375
- "error": err,
1376
- "hint": hint,
1377
- "task_id": task_id,
1378
- "protocol_strictness": protocol_strictness,
1379
- "evidence_quality_reason": trivial_reason,
1380
- },
1381
- ensure_ascii=False,
1382
- indent=2,
1383
- )
1384
- _record_debt(
1386
+ debt = _ensure_open_debt(
1385
1387
  task["session_id"],
1386
1388
  task_id,
1387
1389
  "claimed_done_without_evidence",
@@ -1393,6 +1395,39 @@ def handle_task_close(
1393
1395
  ),
1394
1396
  debts=debts_created,
1395
1397
  )
1398
+ if trivial_reason == "empty":
1399
+ err = "Cannot close task as 'done' without evidence."
1400
+ hint = (
1401
+ "Provide the `evidence` parameter with verifiable proof: "
1402
+ "test output, curl response, screenshot path, or real "
1403
+ "command output."
1404
+ )
1405
+ else:
1406
+ err = (
1407
+ "Cannot close task as 'done' with trivial evidence "
1408
+ f"({trivial_reason})."
1409
+ )
1410
+ hint = (
1411
+ f"Evidence must be substantive: >= {R03_MIN_EVIDENCE_CHARS} "
1412
+ "characters AND not a single filler word. Attach real "
1413
+ "proof — test output excerpt, curl response, DB row, "
1414
+ "screenshot path, or command stdout."
1415
+ )
1416
+ return json.dumps(
1417
+ {
1418
+ "ok": False,
1419
+ "error": err,
1420
+ "hint": hint,
1421
+ "task_id": task_id,
1422
+ "blocked_by": "g1_verify",
1423
+ "debt_id": debt.get("id"),
1424
+ "debt_type": "claimed_done_without_evidence",
1425
+ "evidence_quality_reason": trivial_reason,
1426
+ "protocol_strictness": get_protocol_strictness(),
1427
+ },
1428
+ ensure_ascii=False,
1429
+ indent=2,
1430
+ )
1396
1431
 
1397
1432
  # ── Release checklist: require channel alignment evidence for release tasks ──
1398
1433
  is_release = _is_release_task(
@@ -1430,7 +1465,7 @@ def handle_task_close(
1430
1465
  (clean_change_verify or clean_evidence)[:500],
1431
1466
  )
1432
1467
  if "error" in change:
1433
- _record_debt(
1468
+ debt = _ensure_open_debt(
1434
1469
  task["session_id"],
1435
1470
  task_id,
1436
1471
  "missing_change_log",
@@ -1438,6 +1473,21 @@ def handle_task_close(
1438
1473
  evidence=f"change_log failed: {change['error']}",
1439
1474
  debts=debts_created,
1440
1475
  )
1476
+ if clean_outcome == "done":
1477
+ return json.dumps(
1478
+ {
1479
+ "ok": False,
1480
+ "error": "Cannot close task as 'done' because change_log creation failed.",
1481
+ "hint": "Capture the changed files and create the change log successfully before closing as done.",
1482
+ "task_id": task_id,
1483
+ "blocked_by": "g1_change_log",
1484
+ "debt_id": debt.get("id"),
1485
+ "debt_type": "missing_change_log",
1486
+ "change_log_error": change.get("error"),
1487
+ },
1488
+ ensure_ascii=False,
1489
+ indent=2,
1490
+ )
1441
1491
  else:
1442
1492
  change_log_id = change.get("id")
1443
1493
  resolve_protocol_debts(
@@ -1446,7 +1496,7 @@ def handle_task_close(
1446
1496
  resolution="Change log created by nexo_task_close",
1447
1497
  )
1448
1498
  else:
1449
- _record_debt(
1499
+ debt = _ensure_open_debt(
1450
1500
  task["session_id"],
1451
1501
  task_id,
1452
1502
  "missing_change_log",
@@ -1454,6 +1504,20 @@ def handle_task_close(
1454
1504
  evidence="Task required change_log but no changed files were supplied or recorded.",
1455
1505
  debts=debts_created,
1456
1506
  )
1507
+ if clean_outcome == "done":
1508
+ return json.dumps(
1509
+ {
1510
+ "ok": False,
1511
+ "error": "Cannot close task as 'done' without changed files for the required change_log.",
1512
+ "hint": "Pass `files_changed` (or open the task with files) so nexo_task_close can persist the change log before closing as done.",
1513
+ "task_id": task_id,
1514
+ "blocked_by": "g1_change_log",
1515
+ "debt_id": debt.get("id"),
1516
+ "debt_type": "missing_change_log",
1517
+ },
1518
+ ensure_ascii=False,
1519
+ indent=2,
1520
+ )
1457
1521
 
1458
1522
  if correction:
1459
1523
  if (learning_title or "").strip() and (learning_content or "").strip():
@@ -1564,7 +1628,7 @@ def handle_task_close(
1564
1628
  resolution="High-stakes action task has a persisted Cortex evaluation.",
1565
1629
  )
1566
1630
  else:
1567
- _record_debt(
1631
+ debt = _ensure_open_debt(
1568
1632
  task["session_id"],
1569
1633
  task_id,
1570
1634
  "missing_cortex_evaluation",
@@ -1572,6 +1636,20 @@ def handle_task_close(
1572
1636
  evidence="High-stakes action task closed without nexo_cortex_decide / persisted evaluation.",
1573
1637
  debts=debts_created,
1574
1638
  )
1639
+ if clean_outcome == "done":
1640
+ return json.dumps(
1641
+ {
1642
+ "ok": False,
1643
+ "error": "Cannot close high-stakes action task as 'done' without a persisted cortex evaluation.",
1644
+ "hint": "Run `nexo_cortex_decide(...)` for this task and then close it again with the final evidence.",
1645
+ "task_id": task_id,
1646
+ "blocked_by": "g1_cortex",
1647
+ "debt_id": debt.get("id"),
1648
+ "debt_type": "missing_cortex_evaluation",
1649
+ },
1650
+ ensure_ascii=False,
1651
+ indent=2,
1652
+ )
1575
1653
 
1576
1654
  if task.get("guard_has_blocking") and not files_changed_list:
1577
1655
  open_task_debts = list_protocol_debts(status="open", task_id=task_id, limit=200)
@@ -18,6 +18,7 @@ Runs via launchd at 7:00 AM daily.
18
18
  """
19
19
  import json
20
20
  import hashlib
21
+ import importlib.util
21
22
  import os
22
23
  import py_compile
23
24
  import re
@@ -1901,6 +1902,31 @@ def _sync_managed_bootstraps_inline() -> list[str]:
1901
1902
  return results
1902
1903
 
1903
1904
 
1905
+ def _run_protocol_debt_drain_inline() -> dict:
1906
+ try:
1907
+ phase_path = NEXO_CODE / "scripts" / "deep-sleep" / "phase_protocol_debt_drain.py"
1908
+ spec = importlib.util.spec_from_file_location("phase_protocol_debt_drain_inline", phase_path)
1909
+ if not spec or not spec.loader:
1910
+ raise RuntimeError(f"Cannot load phase module from {phase_path}")
1911
+ module = importlib.util.module_from_spec(spec)
1912
+ spec.loader.exec_module(module)
1913
+ except Exception as exc:
1914
+ return {"ok": False, "error": f"import_failed: {exc}"}
1915
+
1916
+ try:
1917
+ report = module.run()
1918
+ except Exception as exc:
1919
+ return {"ok": False, "error": f"run_failed: {exc}"}
1920
+
1921
+ return {
1922
+ "ok": "error" not in report,
1923
+ "error": report.get("error", ""),
1924
+ "drained_count": len(report.get("drained_ids") or []),
1925
+ "requires_user_summary": report.get("requires_user_summary") or [],
1926
+ "audit_path": report.get("audit_path", ""),
1927
+ }
1928
+
1929
+
1904
1930
  def _sanitize_watchdog_registry_inline() -> dict:
1905
1931
  hash_registry = _hash_registry_path()
1906
1932
  if not hash_registry.exists():
@@ -1984,6 +2010,24 @@ def _disable_broken_personal_plugins_inline(conn: sqlite3.Connection | None) ->
1984
2010
  def run_mechanical_autofixes():
1985
2011
  conn = None
1986
2012
  try:
2013
+ debt_drain = _run_protocol_debt_drain_inline()
2014
+ if debt_drain.get("ok"):
2015
+ drained_count = int(debt_drain.get("drained_count") or 0)
2016
+ requires_user_summary = debt_drain.get("requires_user_summary") or []
2017
+ if drained_count or requires_user_summary:
2018
+ detail_bits: list[str] = []
2019
+ if drained_count:
2020
+ detail_bits.append(f"drained {drained_count} stale protocol debt item(s)")
2021
+ if requires_user_summary:
2022
+ summary = ", ".join(
2023
+ f"{item.get('debt_type')} x{int(item.get('count') or 0)}"
2024
+ for item in requires_user_summary[:4]
2025
+ )
2026
+ detail_bits.append(f"still needs user review: {summary}")
2027
+ finding("INFO", "autofix", "Self-audit protocol debt drain: " + " | ".join(detail_bits))
2028
+ elif debt_drain.get("error"):
2029
+ finding("WARN", "autofix", f"Protocol debt drain inline failed: {debt_drain['error']}")
2030
+
1987
2031
  if NEXO_DB.exists():
1988
2032
  conn = sqlite3.connect(str(NEXO_DB))
1989
2033
  conn.row_factory = sqlite3.Row