nexo-brain 5.0.4 → 5.1.0
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 +12 -0
- package/package.json +1 -1
- package/src/auto_update.py +291 -1
- package/src/cognitive/_ingest.py +3 -1
- package/src/cognitive/_memory.py +5 -1
- package/src/cognitive/_search.py +115 -3
- package/src/crons/manifest.json +12 -0
- package/src/db/_core.py +1 -1
- package/src/db/_reminders.py +36 -0
- package/src/db/_schema.py +52 -0
- package/src/hook_observability.py +293 -0
- package/src/hooks/session-start.sh +27 -0
- package/src/knowledge_graph.py +179 -0
- package/src/maintenance.py +53 -62
- package/src/observability.py +199 -0
- package/src/plugins/adaptive_mode.py +55 -1
- package/src/plugins/backup.py +14 -3
- package/src/plugins/knowledge_graph_tools.py +32 -0
- package/src/plugins/protocol.py +2 -1
- package/src/plugins/simple_api.py +4 -1
- package/src/plugins/skills.py +32 -0
- package/src/retroactive_learnings.py +370 -0
- package/src/scripts/check-context.py +2 -2
- package/src/scripts/deep-sleep/apply_findings.py +131 -4
- package/src/scripts/deep-sleep/synthesize.py +3 -1
- package/src/scripts/nexo-cognitive-decay.py +75 -0
- package/src/scripts/nexo-cortex-cycle.py +266 -0
- package/src/scripts/nexo-daily-self-audit.py +4 -2
- package/src/scripts/nexo-evolution-run.py +174 -7
- package/src/scripts/nexo-hook-record.py +42 -0
- package/src/scripts/nexo-outcome-checker.py +30 -0
- package/src/server.py +84 -0
- package/src/skills/run-release-final-audit/guide.md +14 -0
- package/src/skills/run-release-final-audit/script.py +177 -0
- package/src/skills/run-release-final-audit/skill.json +64 -0
- package/src/skills_runtime.py +231 -0
- package/src/state_watchers_runtime.py +134 -0
- package/src/tools_learnings.py +25 -1
- package/src/tools_menu.py +1 -0
- package/src/tools_sessions.py +77 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
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
|
|
3
|
+
"version": "5.1.0",
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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
|
|
package/src/cognitive/_ingest.py
CHANGED
|
@@ -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
|
package/src/cognitive/_memory.py
CHANGED
|
@@ -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
|
|
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
|
|
package/src/cognitive/_search.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/crons/manifest.json
CHANGED
|
@@ -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=
|
|
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
|
package/src/db/_reminders.py
CHANGED
|
@@ -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(
|