nexo-brain 5.0.4 → 5.1.1

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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -0
  3. package/package.json +1 -1
  4. package/src/auto_update.py +291 -1
  5. package/src/cognitive/_ingest.py +3 -1
  6. package/src/cognitive/_memory.py +5 -1
  7. package/src/cognitive/_search.py +115 -3
  8. package/src/crons/manifest.json +12 -0
  9. package/src/db/_core.py +1 -1
  10. package/src/db/_reminders.py +36 -0
  11. package/src/db/_schema.py +52 -0
  12. package/src/doctor/providers/runtime.py +132 -0
  13. package/src/hook_observability.py +293 -0
  14. package/src/hooks/session-start.sh +27 -0
  15. package/src/knowledge_graph.py +179 -0
  16. package/src/maintenance.py +53 -62
  17. package/src/observability.py +199 -0
  18. package/src/plugins/adaptive_mode.py +55 -1
  19. package/src/plugins/backup.py +14 -3
  20. package/src/plugins/episodic_memory.py +13 -1
  21. package/src/plugins/knowledge_graph_tools.py +32 -0
  22. package/src/plugins/protocol.py +2 -1
  23. package/src/plugins/simple_api.py +4 -1
  24. package/src/plugins/skills.py +32 -0
  25. package/src/retroactive_learnings.py +370 -0
  26. package/src/scripts/check-context.py +2 -2
  27. package/src/scripts/deep-sleep/apply_findings.py +131 -4
  28. package/src/scripts/deep-sleep/synthesize.py +3 -1
  29. package/src/scripts/nexo-cognitive-decay.py +75 -0
  30. package/src/scripts/nexo-cortex-cycle.py +266 -0
  31. package/src/scripts/nexo-daily-self-audit.py +85 -5
  32. package/src/scripts/nexo-evolution-run.py +174 -7
  33. package/src/scripts/nexo-hook-record.py +42 -0
  34. package/src/scripts/nexo-outcome-checker.py +30 -0
  35. package/src/server.py +84 -0
  36. package/src/skills/run-release-final-audit/guide.md +14 -0
  37. package/src/skills/run-release-final-audit/script.py +177 -0
  38. package/src/skills/run-release-final-audit/skill.json +64 -0
  39. package/src/skills_runtime.py +231 -0
  40. package/src/state_watchers_runtime.py +134 -0
  41. package/src/tools_learnings.py +25 -1
  42. package/src/tools_menu.py +1 -0
  43. package/src/tools_sessions.py +77 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.0.4",
3
+ "version": "5.1.1",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -87,6 +87,18 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
87
87
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
88
88
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
89
89
 
90
+ Version `5.1.0` lands the full NEXO-AUDIT-2026-04-11 roadmap as a single minor bump — every open evolution / adaptive / cognitive / skills loop now closes under itself, the knowledge graph exports cleanly, OpenTelemetry spans can be turned on without a hard dependency, and every PR has to clear lint, security, coverage, and release-readiness gates before it can merge:
91
+
92
+ - Evolution cycle now auto-applies user-approved proposals on the next run (backed by the new idempotent migration `m38`), adaptive learned-weight rollbacks surface as visible followups, outcome patterns auto-promote to draft skills, and a Voyager-style detector exposes co-occurring skill pairs as composite-skill candidates via `nexo_skill_compose_candidates`.
93
+ - `cognitive._search.search()` now accepts `dream_weight` and reranks dream-insights through it, somatic markers fold into the same reranking path (max +0.10 boost), state watchers open and auto-resolve deterministic `NF-WATCHER-{id}` followups, and correction fatigue opens a visible followup instead of only decaying memory.
94
+ - A new Cortex quality cron (every 6h) watches accept rate / linked-success / override gap and opens `NF-CORTEX-QUALITY-DROP` idempotently when the decision engine starts drifting between cycles.
95
+ - Adding a new learning now walks recent decisions through `retroactive_learnings.apply_learning_retroactively()` and opens deterministic `NF-RETRO-L<id>-D<id>` followups for every decision the learning would have changed (exposed via `nexo_learning_apply_retroactively`).
96
+ - Hook lifecycle observability: new `hook_runs` table (migration `m39`) + `nexo_hook_runs` tool expose recent hook runs, failure streaks, and a health summary. Hook drops are no longer invisible.
97
+ - Knowledge graph bitemporal export: `nexo_kg_export` emits JSON-LD (with an `nexo:*` vocabulary) or GraphML, and accepts an `as_of` ISO timestamp that replays the historical snapshot through `kg_edges.valid_from / valid_until` for igraph, Gephi, NetworkX, and Cytoscape.
98
+ - OpenTelemetry integration: new `src/observability.py` soft-imports `opentelemetry` and only activates when `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_SERVICE_NAME` is set. `tool_span()` becomes a real span when enabled and stays a no-op context manager when disabled.
99
+ - CI gates on every PR: new workflows enforce ruff (`E9 / F63 / F7 / F82 / F821`), bandit at high severity / high confidence, coverage baselines, and `verify_release_readiness.py --ci`. A PR that breaks the release contract fails loudly instead of waiting until tag push.
100
+ - Safer update path: `auto_update` is guarded by a POSIX `flock` with stale-steal at 10 minutes, and on macOS it now `launchctl unload`s and reloads every `com.nexo.*.plist` after a version bump so long-lived crons pick up the new codebase immediately.
101
+
90
102
  Version `5.0.4` tightens the local runtime bridge and trims false-positive doctor noise:
91
103
 
92
104
  - vendorable `nexo_helper.py` now resolves `NEXO_HOME` and the `nexo` CLI path robustly, so personal scripts and subprocess flows stop depending on a lucky PATH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.0.4",
3
+ "version": "5.1.1",
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",
@@ -355,6 +355,137 @@ def _sync_crons():
355
355
  _log(f"Cron sync warning: {e}")
356
356
 
357
357
 
358
+ def _reload_launch_agents_after_bump() -> dict:
359
+ """Unload+load NEXO LaunchAgents so they pick up the new code on next fire.
360
+
361
+ Closes Bloque D of NEXO-AUDIT-2026-04-11 (learning #186 from Fase 1).
362
+ Until this helper, `nexo update` would `git pull` the new code into
363
+ NEXO_CODE but the 40+ LaunchAgents already running held the old
364
+ Python modules in memory until macOS happened to restart them. With
365
+ a single function call we explicitly tell launchd to reload the
366
+ plist files so the next fire reads the fresh code.
367
+
368
+ Best-effort throughout — a failure here must NEVER block the update
369
+ that just succeeded. Returns a dict with what was attempted so the
370
+ caller can log a single summary line.
371
+
372
+ Returns:
373
+ {
374
+ "scanned": N, # plists found in ~/Library/LaunchAgents
375
+ "reloaded": N, # plists where unload+load both succeeded
376
+ "skipped_missing": N, # plist file vanished mid-scan
377
+ "errors": [{plist, stderr}],
378
+ }
379
+
380
+ Linux equivalent: systemctl --user daemon-reload + restart of timer
381
+ units. Implemented as a no-op stub on Linux for now (the macOS
382
+ LaunchAgent path is the production target — Linux users running
383
+ `nexo update` get the cron sync but not the per-timer restart yet).
384
+ Captured as a TODO for the next round.
385
+ """
386
+ result: dict = {
387
+ "scanned": 0,
388
+ "reloaded": 0,
389
+ "skipped_missing": 0,
390
+ "errors": [],
391
+ "platform": sys.platform,
392
+ }
393
+
394
+ if sys.platform != "darwin":
395
+ # macOS-only for now. systemd path tracked separately.
396
+ return result
397
+
398
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
399
+ if not launch_agents_dir.is_dir():
400
+ return result
401
+
402
+ try:
403
+ plists = sorted(launch_agents_dir.glob("com.nexo.*.plist"))
404
+ except Exception as e:
405
+ result["errors"].append({"plist": "*", "stderr": f"glob failed: {e}"})
406
+ return result
407
+
408
+ result["scanned"] = len(plists)
409
+ for plist in plists:
410
+ try:
411
+ if not plist.is_file():
412
+ result["skipped_missing"] += 1
413
+ continue
414
+ # launchctl bootout / bootstrap is the modern API but requires
415
+ # the GUI session id ($UID/Background or gui/$UID). The legacy
416
+ # unload + load -w pair still works on every macOS NEXO supports
417
+ # and does not need a session id, so we use it here.
418
+ unload_proc = subprocess.run(
419
+ ["launchctl", "unload", str(plist)],
420
+ capture_output=True, text=True, timeout=10,
421
+ )
422
+ # unload returns non-zero if the agent was not loaded — that
423
+ # is fine, we still try to load fresh.
424
+ load_proc = subprocess.run(
425
+ ["launchctl", "load", "-w", str(plist)],
426
+ capture_output=True, text=True, timeout=10,
427
+ )
428
+ if load_proc.returncode == 0:
429
+ result["reloaded"] += 1
430
+ else:
431
+ result["errors"].append({
432
+ "plist": plist.name,
433
+ "stderr": (load_proc.stderr or load_proc.stdout or "load failed")[:300],
434
+ })
435
+ except subprocess.TimeoutExpired:
436
+ result["errors"].append({"plist": plist.name, "stderr": "launchctl timeout"})
437
+ except Exception as e:
438
+ result["errors"].append({"plist": plist.name, "stderr": str(e)[:300]})
439
+
440
+ return result
441
+
442
+
443
+ AUTO_UPDATE_BACKUP_KEEP = 10
444
+ """Maximum number of auto-update backups to keep per prefix.
445
+
446
+ Both `pre-autoupdate-*/` (DB snapshots) and `runtime-tree-*/` (code mirrors)
447
+ were accumulating indefinitely, growing to tens of GB on long-running
448
+ installs. Rotating to the N most recent keeps a meaningful rollback window
449
+ without unbounded disk use."""
450
+
451
+
452
+ def _rotate_auto_update_backups(prefix: str, keep: int = AUTO_UPDATE_BACKUP_KEEP) -> int:
453
+ """Delete old auto-update backup directories matching a prefix, keeping `keep` most recent.
454
+
455
+ Silent on failures — cleanup must never interrupt the auto-update flow.
456
+ Returns number of entries removed (0 on failure or nothing to prune).
457
+ """
458
+ if keep <= 0:
459
+ return 0
460
+ base = NEXO_HOME / "backups"
461
+ if not base.is_dir():
462
+ return 0
463
+ try:
464
+ candidates = [p for p in base.iterdir() if p.is_dir() and p.name.startswith(prefix)]
465
+ except Exception as e:
466
+ _log(f"Backup rotation scan warning ({prefix}): {e}")
467
+ return 0
468
+ if len(candidates) <= keep:
469
+ return 0
470
+ # Newest first by modification time, then delete everything beyond `keep`
471
+ try:
472
+ candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
473
+ except Exception as e:
474
+ _log(f"Backup rotation sort warning ({prefix}): {e}")
475
+ return 0
476
+ removed = 0
477
+ import shutil as _shutil
478
+ for old in candidates[keep:]:
479
+ try:
480
+ _shutil.rmtree(str(old))
481
+ removed += 1
482
+ except Exception as e:
483
+ _log(f"Backup rotation remove warning ({old.name}): {e}")
484
+ if removed:
485
+ _log(f"Rotated {removed} old {prefix}* backup(s), kept {keep} most recent")
486
+ return removed
487
+
488
+
358
489
  def _backup_dbs() -> str | None:
359
490
  """Snapshot all .db files before migration. Returns backup dir or None."""
360
491
  import sqlite3
@@ -388,6 +519,14 @@ def _backup_dbs() -> str | None:
388
519
  conn.close()
389
520
  except Exception:
390
521
  pass
522
+ # Opportunistic rotation: keep only the N most recent pre-autoupdate dirs.
523
+ # Failures here must never bubble up — the caller depends on the backup
524
+ # path string for rollback and should not see spurious exceptions from
525
+ # housekeeping of older entries.
526
+ try:
527
+ _rotate_auto_update_backups("pre-autoupdate-")
528
+ except Exception as e:
529
+ _log(f"Backup rotation warning (pre-autoupdate): {e}")
391
530
  return str(backup_dir)
392
531
 
393
532
 
@@ -531,6 +670,28 @@ def _check_git_updates() -> str | None:
531
670
  # Sync cron definitions with manifest
532
671
  _sync_crons()
533
672
 
673
+ # Bloque D / learning #186: when the package version actually
674
+ # changed, reload the LaunchAgents so the 40+ background crons
675
+ # pick up the new code on their next fire instead of holding the
676
+ # old Python modules in memory until macOS happens to restart them.
677
+ # Best-effort — never blocks the update flow.
678
+ if old_version != new_version:
679
+ try:
680
+ reload_summary = _reload_launch_agents_after_bump()
681
+ if reload_summary.get("reloaded"):
682
+ _log(
683
+ f"Reloaded {reload_summary['reloaded']}/{reload_summary['scanned']} "
684
+ f"NEXO LaunchAgents after version bump"
685
+ + (f" ({len(reload_summary['errors'])} errors)" if reload_summary["errors"] else "")
686
+ )
687
+ elif reload_summary.get("scanned"):
688
+ _log(
689
+ f"LaunchAgent reload after bump: scanned {reload_summary['scanned']}, "
690
+ f"reloaded 0, errors {len(reload_summary['errors'])}"
691
+ )
692
+ except Exception as e:
693
+ _log(f"LaunchAgent reload after bump failed: {e}")
694
+
534
695
  msg = f"Auto-updated: {old_version} -> {new_version}" if old_version != new_version else f"Auto-updated (v{new_version}, new commits)"
535
696
  _log(msg)
536
697
  return msg
@@ -873,6 +1034,101 @@ def _sync_client_bootstraps(preferences: dict | None = None) -> list[str]:
873
1034
 
874
1035
  # ── Main entry point ─────────────────────────────────────────────────
875
1036
 
1037
+ _AUTO_UPDATE_LOCK_FILE = NEXO_HOME / "operations" / ".auto_update.lock"
1038
+ _AUTO_UPDATE_LOCK_STALE_SECONDS = 600 # 10 minutes
1039
+
1040
+
1041
+ def _acquire_auto_update_lock() -> tuple[bool, object | None, str]:
1042
+ """Acquire an exclusive non-blocking lock on the auto_update lockfile.
1043
+
1044
+ Closes NF-AUDIT-2026-04-11-UPDATE-LOCK. Two NEXO terminals starting at
1045
+ the same moment after a version bump used to race on
1046
+ auto_update_check(): they would both run run_migrations(),
1047
+ _check_git_updates(), and the file/hooks sync, occasionally tripping
1048
+ UNIQUE constraints on schema_migrations or producing torn writes on
1049
+ shared files.
1050
+
1051
+ The lock uses fcntl.flock(LOCK_EX | LOCK_NB) so the second caller
1052
+ returns instantly with a clean "skipped_reason=locked_by_other_process"
1053
+ rather than blocking the server startup. The lock file persists across
1054
+ crashes — we treat any lock older than 10 minutes as stale and steal
1055
+ it, so a hard kill mid-update never wedges future runs forever.
1056
+
1057
+ Returns:
1058
+ (acquired, fh, reason)
1059
+ - acquired: True if we now hold the lock, False otherwise.
1060
+ - fh: the open file handle (caller MUST close it after release).
1061
+ - reason: human-readable explanation when not acquired.
1062
+ """
1063
+ try:
1064
+ _AUTO_UPDATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
1065
+ except Exception as e:
1066
+ return False, None, f"cannot create lock directory: {e}"
1067
+
1068
+ # Steal stale locks: if the lockfile exists and was last modified more
1069
+ # than 10 minutes ago, assume the previous holder crashed and reset it.
1070
+ try:
1071
+ if _AUTO_UPDATE_LOCK_FILE.exists():
1072
+ age = time.time() - _AUTO_UPDATE_LOCK_FILE.stat().st_mtime
1073
+ if age > _AUTO_UPDATE_LOCK_STALE_SECONDS:
1074
+ try:
1075
+ _AUTO_UPDATE_LOCK_FILE.unlink()
1076
+ except Exception:
1077
+ pass # Will fall through to the open below
1078
+ except Exception:
1079
+ pass
1080
+
1081
+ try:
1082
+ fh = open(_AUTO_UPDATE_LOCK_FILE, "a+")
1083
+ except Exception as e:
1084
+ return False, None, f"cannot open lock file: {e}"
1085
+
1086
+ try:
1087
+ import fcntl
1088
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1089
+ except ImportError:
1090
+ # Non-POSIX platform. Best-effort: write a PID stamp and proceed.
1091
+ try:
1092
+ fh.seek(0)
1093
+ fh.truncate()
1094
+ fh.write(f"{os.getpid()}:{time.time()}\n")
1095
+ fh.flush()
1096
+ except Exception:
1097
+ pass
1098
+ return True, fh, ""
1099
+ except (OSError, BlockingIOError):
1100
+ try:
1101
+ fh.close()
1102
+ except Exception:
1103
+ pass
1104
+ return False, None, "locked_by_other_process"
1105
+
1106
+ # We have the lock. Stamp PID + timestamp so observers can see who.
1107
+ try:
1108
+ fh.seek(0)
1109
+ fh.truncate()
1110
+ fh.write(f"{os.getpid()}:{time.time()}\n")
1111
+ fh.flush()
1112
+ except Exception:
1113
+ pass
1114
+ return True, fh, ""
1115
+
1116
+
1117
+ def _release_auto_update_lock(fh: object | None) -> None:
1118
+ """Release the lock acquired by _acquire_auto_update_lock and close the fd."""
1119
+ if fh is None:
1120
+ return
1121
+ try:
1122
+ import fcntl
1123
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN) # type: ignore[attr-defined]
1124
+ except Exception:
1125
+ pass
1126
+ try:
1127
+ fh.close() # type: ignore[attr-defined]
1128
+ except Exception:
1129
+ pass
1130
+
1131
+
876
1132
  def auto_update_check() -> dict:
877
1133
  """Run the full auto-update check at server startup.
878
1134
 
@@ -887,6 +1143,12 @@ def auto_update_check() -> dict:
887
1143
  - git fetch/pull (if git repo)
888
1144
  - npm version check (if non-git install)
889
1145
 
1146
+ Concurrency:
1147
+ Wrapped in a non-blocking exclusive flock so a second concurrent
1148
+ terminal returns instantly with skipped_reason='locked_by_other_process'
1149
+ instead of racing on run_migrations / git pull / file sync. Stale
1150
+ locks (>10 minutes) are auto-stolen.
1151
+
890
1152
  Returns a dict with:
891
1153
  - checked: bool — whether a network check was actually performed
892
1154
  - git_update: str|None — git update status message
@@ -895,9 +1157,30 @@ def auto_update_check() -> dict:
895
1157
  - client_bootstrap_updates: list[str] — Codex/Claude bootstrap sync statuses
896
1158
  - migrations: list — file-based migration results
897
1159
  - db_migrations: int — number of DB schema migrations applied
898
- - skipped_reason: str|None — why the network check was skipped (cooldown, etc.)
1160
+ - skipped_reason: str|None — why the network check was skipped (cooldown, locked, etc.)
899
1161
  - error: str|None — error message if something failed (informational only)
900
1162
  """
1163
+ acquired, lock_fh, lock_reason = _acquire_auto_update_lock()
1164
+ if not acquired:
1165
+ return {
1166
+ "checked": False,
1167
+ "git_update": None,
1168
+ "npm_notice": None,
1169
+ "claude_md_update": None,
1170
+ "client_bootstrap_updates": [],
1171
+ "migrations": [],
1172
+ "db_migrations": 0,
1173
+ "skipped_reason": lock_reason or "locked_by_other_process",
1174
+ "error": None,
1175
+ }
1176
+ try:
1177
+ return _auto_update_check_locked()
1178
+ finally:
1179
+ _release_auto_update_lock(lock_fh)
1180
+
1181
+
1182
+ def _auto_update_check_locked() -> dict:
1183
+ """Inner body of auto_update_check, executed while holding the lockfile."""
901
1184
  result = {
902
1185
  "checked": False,
903
1186
  "git_update": None,
@@ -1315,6 +1598,13 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1315
1598
  if (dest / "bin").is_dir():
1316
1599
  import shutil
1317
1600
  shutil.copytree(str(dest / "bin"), str(backup_dir / "bin"), dirs_exist_ok=True)
1601
+ # Opportunistic rotation: runtime-tree snapshots were accumulating forever
1602
+ # because nothing ever pruned them. Keep only the N most recent; failures
1603
+ # must never block the runtime-tree caller's rollback flow.
1604
+ try:
1605
+ _rotate_auto_update_backups("runtime-tree-")
1606
+ except Exception as e:
1607
+ _log(f"Backup rotation warning (runtime-tree): {e}")
1318
1608
  return str(backup_dir)
1319
1609
 
1320
1610
 
@@ -353,8 +353,10 @@ def prediction_error_gate(
353
353
 
354
354
  if best_score > threshold:
355
355
  # Check for siblings before rejecting -- if discriminating entities differ,
356
- # this is NOT a duplicate, it's a sibling (same fix for different platforms)
356
+ # this is NOT a duplicate, it's a sibling (same fix for different platforms).
357
+ # Lazy import to avoid the cognitive._memory <-> cognitive._ingest cycle.
357
358
  if best_match:
359
+ from cognitive._memory import _memories_are_siblings
358
360
  is_sibling, discriminators = _memories_are_siblings(content, best_match["content"])
359
361
  if is_sibling:
360
362
  _gate_stats["accepted_novel"] += 1
@@ -1,4 +1,5 @@
1
1
  """NEXO Cognitive — Memory operations: format, stats, consolidation, somatic."""
2
+ import base64
2
3
  import json, math, re
3
4
  import numpy as np
4
5
  from datetime import datetime, timedelta, timezone
@@ -9,7 +10,10 @@ def _utcnow_naive() -> datetime:
9
10
  the legacy ``datetime.utcnow()`` string format on disk.
10
11
  """
11
12
  return datetime.now(timezone.utc).replace(tzinfo=None)
12
- from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob, EMBEDDING_DIM, DISCRIMINATING_ENTITIES
13
+ from cognitive._core import (
14
+ _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
15
+ EMBEDDING_DIM, DISCRIMINATING_ENTITIES, redact_secrets,
16
+ )
13
17
  from cognitive._ingest import _sanitize_memory_content
14
18
 
15
19
 
@@ -278,6 +278,82 @@ def _result_confidence(score: float) -> str:
278
278
  # (structural) worlds.
279
279
  # ============================================================================
280
280
 
281
+ def _somatic_boost_results(results: list[dict], max_boost: float = 0.10) -> list[dict]:
282
+ """Boost search results that touch high-risk somatic targets.
283
+
284
+ Closes Fase 3 item 2 of NEXO-AUDIT-2026-04-11. The somatic_markers table
285
+ is populated by guard hits, error_repetition events, and learning_add
286
+ side effects (see _memory.py:somatic_*). Until this function existed,
287
+ that risk signal was never used to influence retrieval — a memory
288
+ touching a known-painful area got no extra surfacing.
289
+
290
+ Now any retrieved memory whose `domain` matches an area-type marker
291
+ with risk_score > 0.1 receives a positive boost proportional to the
292
+ risk_score. The intent is positive (not penalizing): a high-risk area
293
+ is exactly the context where the agent benefits from extra reminders.
294
+
295
+ Boost formula: min(max_boost, 0.10 * risk_score)
296
+ - risk_score 0.2 -> +0.020
297
+ - risk_score 0.5 -> +0.050
298
+ - risk_score 1.0 -> +0.100 (capped)
299
+
300
+ Result rows that received a boost carry `somatic_boost` (the actual
301
+ boost) and `somatic_risk` (the source risk_score) so dashboards and
302
+ downstream rerankers can identify them.
303
+
304
+ The boost gate matches `_kg_boost_results`: only results already at
305
+ score >= 0.45 receive the boost, so noise from very weak matches is
306
+ not amplified.
307
+ """
308
+ if not results:
309
+ return results
310
+
311
+ try:
312
+ db = _get_db()
313
+ except Exception:
314
+ return results
315
+
316
+ # Collect distinct domains across the result set so we can do a single
317
+ # batched query against somatic_markers instead of one per result.
318
+ domains = {(r.get("domain") or "").strip() for r in results if (r.get("domain") or "").strip()}
319
+ if not domains:
320
+ return results
321
+
322
+ try:
323
+ placeholders = ",".join(["?"] * len(domains))
324
+ rows = db.execute(
325
+ f"SELECT target, risk_score FROM somatic_markers "
326
+ f"WHERE target_type = 'area' AND risk_score > 0.1 "
327
+ f"AND target IN ({placeholders})",
328
+ list(domains),
329
+ ).fetchall()
330
+ except Exception:
331
+ return results
332
+
333
+ if not rows:
334
+ return results
335
+
336
+ risk_by_domain = {row["target"]: float(row["risk_score"] or 0.0) for row in rows}
337
+
338
+ for r in results:
339
+ domain = (r.get("domain") or "").strip()
340
+ if not domain:
341
+ continue
342
+ risk = risk_by_domain.get(domain, 0.0)
343
+ if risk <= 0.1:
344
+ continue
345
+ if r.get("score", 0) < 0.45: # Same relevance gate as KG and temporal
346
+ continue
347
+ boost = min(max_boost, 0.10 * risk)
348
+ if boost <= 0:
349
+ continue
350
+ r["score"] = min(0.95, r["score"] + boost)
351
+ r["somatic_boost"] = round(boost, 4)
352
+ r["somatic_risk"] = round(risk, 4)
353
+
354
+ return results
355
+
356
+
281
357
  def _kg_boost_results(results: list[dict], max_boost: float = 0.08) -> list[dict]:
282
358
  """Boost search results based on Knowledge Graph connectivity.
283
359
 
@@ -726,6 +802,7 @@ def search(
726
802
  spreading_depth: int | None = None,
727
803
  decompose: bool = True,
728
804
  exclude_dreams: bool = True,
805
+ dream_weight: float = 0.0,
729
806
  ) -> list[dict]:
730
807
  """Full vector search across STM and/or LTM with rehearsal and dormant reactivation.
731
808
 
@@ -735,10 +812,29 @@ def search(
735
812
  exclude_dreams: If True (default), exclude dream_insight memories from results.
736
813
  Dream insights are 21% of LTM and dilute search precision.
737
814
  Set to False only when explicitly looking for cross-domain patterns.
815
+ dream_weight: Float in [0.0, 1.0]. Closes Fase 3 item 1 of NEXO-AUDIT-2026-04-11.
816
+ When > 0, dream_insight memories are INCLUDED in retrieval even
817
+ if exclude_dreams=True, but their cosine score is multiplied by
818
+ this weight before the min_score gate. The default 0.0 keeps
819
+ the historical behavior (dreams excluded). Set to 0.5 for
820
+ "include dreams at half importance" or 1.0 for "treat dreams
821
+ like any other memory". When dream_weight > 0, the result rows
822
+ that came from dream_insight carry a `dream_weighted=True` flag
823
+ so dashboards and downstream rerankers can identify them.
738
824
  hybrid: If True, boost results with BM25 keyword matches (default True)
739
825
  hybrid_alpha: Weight for vector vs BM25. Higher = more vector. (default 0.6)
740
826
  decompose: If True, decompose complex queries into sub-queries for better multi-hop (default True)
741
827
  """
828
+ # Normalize dream_weight to [0.0, 1.0]; >0 effectively overrides exclude_dreams.
829
+ try:
830
+ dream_weight = float(dream_weight or 0.0)
831
+ except (TypeError, ValueError):
832
+ dream_weight = 0.0
833
+ if dream_weight < 0.0:
834
+ dream_weight = 0.0
835
+ elif dream_weight > 1.0:
836
+ dream_weight = 1.0
837
+ _include_dreams_with_weight = dream_weight > 0.0
742
838
  # Multi-query decomposition: for complex questions, search sub-parts and merge
743
839
  if decompose and query_text:
744
840
  _connectors = [" after ", " before ", " because ", " and then ", " when ", " while "]
@@ -856,7 +952,8 @@ def search(
856
952
  if source_type_filter:
857
953
  where += " AND source_type = ?"
858
954
  params.append(source_type_filter)
859
- if exclude_dreams and not source_type_filter:
955
+ # Fase 3 item 1: dream_weight > 0 lets dreams in even when exclude_dreams=True.
956
+ if exclude_dreams and not source_type_filter and not _include_dreams_with_weight:
860
957
  where += " AND source_type != 'dream_insight'"
861
958
  rows = db.execute(f"SELECT * FROM ltm_memories {where}", params).fetchall()
862
959
 
@@ -869,8 +966,14 @@ def search(
869
966
  lifecycle = row["lifecycle_state"] or "active"
870
967
  if lifecycle == "pinned":
871
968
  score = min(1.0, score + 0.2)
969
+ # Fase 3 item 1: dream_insight rows get their score scaled by dream_weight.
970
+ # The weight applies BEFORE the min_score gate, so a low weight naturally
971
+ # suppresses dreams without requiring a separate filter step.
972
+ is_dream = row["source_type"] == "dream_insight"
973
+ if is_dream and _include_dreams_with_weight:
974
+ score = score * dream_weight
872
975
  if score >= min_score:
873
- results.append({
976
+ entry = {
874
977
  "store": "ltm",
875
978
  "id": row["id"],
876
979
  "content": row["content"],
@@ -884,7 +987,11 @@ def search(
884
987
  "score": score,
885
988
  "tags": row["tags"],
886
989
  "lifecycle_state": lifecycle,
887
- })
990
+ }
991
+ if is_dream and _include_dreams_with_weight:
992
+ entry["dream_weighted"] = True
993
+ entry["dream_weight_applied"] = dream_weight
994
+ results.append(entry)
888
995
 
889
996
  # Check dormant LTM for reactivation
890
997
  if stores in ("both", "ltm") and not exclude_dormant:
@@ -936,6 +1043,11 @@ def search(
936
1043
  # Knowledge Graph structural boost: connected memories rank higher
937
1044
  results = _kg_boost_results(results)
938
1045
 
1046
+ # Fase 3 item 2: somatic risk boost — memories whose domain matches a
1047
+ # high-risk area marker get a small positive lift so the agent surfaces
1048
+ # warnings about painful areas more aggressively.
1049
+ results = _somatic_boost_results(results)
1050
+
939
1051
  # Sort by score descending, take top-20 for reranking
940
1052
  results.sort(key=lambda x: x.get("score", 0), reverse=True)
941
1053
 
@@ -16,6 +16,18 @@
16
16
  "run_on_boot": true,
17
17
  "run_on_wake": true
18
18
  },
19
+ {
20
+ "id": "cortex-cycle",
21
+ "script": "scripts/nexo-cortex-cycle.py",
22
+ "interval_seconds": 21600,
23
+ "description": "Continuous Cortex quality validation — every 6h. Persists snapshot and opens NF-CORTEX-QUALITY-DROP followup on degradation",
24
+ "core": true,
25
+ "recovery_policy": "catchup",
26
+ "idempotent": true,
27
+ "max_catchup_age": 86400,
28
+ "run_on_boot": false,
29
+ "run_on_wake": false
30
+ },
19
31
  {
20
32
  "id": "sleep",
21
33
  "script": "scripts/nexo-sleep.py",
package/src/db/_core.py CHANGED
@@ -53,7 +53,7 @@ def get_db() -> sqlite3.Connection:
53
53
  raw.execute("PRAGMA journal_mode=WAL")
54
54
  raw.execute("PRAGMA busy_timeout=30000")
55
55
  raw.execute("PRAGMA foreign_keys=ON")
56
- raw.execute("PRAGMA wal_autocheckpoint=100")
56
+ raw.execute("PRAGMA wal_autocheckpoint=1000")
57
57
  raw.row_factory = sqlite3.Row
58
58
  _shared_conn = _SerializedConnection(raw)
59
59
  return _shared_conn
@@ -14,6 +14,35 @@ from db._hot_context import capture_context_event
14
14
  ACTIVE_EXCLUDED_STATUSES = {"DELETED", "archived", "blocked", "waiting"}
15
15
  READ_TOKEN_TTL_SECONDS = 30 * 60
16
16
 
17
+ # Opportunistic cleanup of expired item_read_tokens: runs at most once every
18
+ # _READ_TOKEN_PURGE_INTERVAL seconds from inside _issue_item_read_token. This
19
+ # avoids unbounded growth of expired tokens without adding a new cron or
20
+ # relying on maintenance_schedule (which is currently not wired up — its
21
+ # runner check_and_run_overdue is defined but never invoked from anywhere).
22
+ _READ_TOKEN_PURGE_INTERVAL = 3600 # 1 hour
23
+ _last_read_token_purge: float = 0.0
24
+
25
+
26
+ def _purge_expired_read_tokens_if_due(conn: sqlite3.Connection, now: float) -> None:
27
+ """Delete expired item_read_tokens in-band with a 1h throttle.
28
+
29
+ Called from _issue_item_read_token so cleanup rides on normal activity and
30
+ does not require a separate scheduler. Failures are swallowed because
31
+ token issuance must never be blocked by cleanup problems.
32
+ """
33
+ global _last_read_token_purge
34
+ if now - _last_read_token_purge < _READ_TOKEN_PURGE_INTERVAL:
35
+ return
36
+ _last_read_token_purge = now
37
+ try:
38
+ conn.execute(
39
+ "DELETE FROM item_read_tokens WHERE expires_at < ?",
40
+ (now,),
41
+ )
42
+ except Exception:
43
+ # Cleanup must never block token issuance. Swallow and move on.
44
+ pass
45
+
17
46
 
18
47
  def _table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
19
48
  row = conn.execute(
@@ -133,6 +162,13 @@ def get_item_history(item_type: str, item_id: str, limit: int = 20) -> list[dict
133
162
  def _issue_item_read_token(item_type: str, item_id: str, ttl_seconds: int = READ_TOKEN_TTL_SECONDS) -> str:
134
163
  conn = get_db()
135
164
  now = now_epoch()
165
+ # Opportunistic cleanup of expired tokens so the table does not grow
166
+ # unbounded. Throttled to once per hour. Wrapped defensively: any
167
+ # failure inside the cleanup helper must never block token issuance.
168
+ try:
169
+ _purge_expired_read_tokens_if_due(conn, now)
170
+ except Exception:
171
+ pass
136
172
  token = "IRT-" + secrets.token_hex(12)
137
173
  history_seq = _latest_history_seq(conn, item_type, item_id)
138
174
  conn.execute(