nexo-brain 7.9.14 → 7.9.17

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.13",
3
+ "version": "7.9.17",
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
@@ -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.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.
21
+ Version `7.9.17` is the current packaged-runtime line. Patch release over `7.9.16`: continuity snapshot idempotency now marks its SHA-1 digest as non-security usage, keeping the high-severity Bandit gate green while preserving stable idempotency keys. It includes the v7.9.16 restart-marker deadlock fix.
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.14",
3
+ "version": "7.9.17",
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",
@@ -558,8 +558,9 @@ def _get_model():
558
558
  """Lazy-load fastembed TextEmbedding model."""
559
559
  global _model
560
560
  if _model is None:
561
- from fastembed import TextEmbedding
562
- _model = TextEmbedding("BAAI/bge-base-en-v1.5")
561
+ from local_models import build_fastembed_embedding
562
+
563
+ _model = build_fastembed_embedding("bge-base-embeddings")
563
564
  return _model
564
565
 
565
566
 
@@ -568,8 +569,9 @@ def _get_reranker():
568
569
  global _reranker
569
570
  if _reranker is None:
570
571
  try:
571
- from fastembed.rerank.cross_encoder import TextCrossEncoder
572
- _reranker = TextCrossEncoder("Xenova/ms-marco-MiniLM-L-6-v2")
572
+ from local_models import build_fastembed_reranker
573
+
574
+ _reranker = build_fastembed_reranker("cross-encoder-reranker")
573
575
  except Exception:
574
576
  _reranker = False # Mark as unavailable
575
577
  return _reranker if _reranker is not False else None
@@ -46,7 +46,7 @@ def build_snapshot_idempotency_key(
46
46
  normalized,
47
47
  ]
48
48
  )
49
- return hashlib.sha1(seed.encode("utf-8")).hexdigest()
49
+ return hashlib.sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()
50
50
 
51
51
 
52
52
  def write_continuity_snapshot(
@@ -195,6 +195,10 @@ def create_cortex_evaluation(
195
195
  goal_profile_id: str = "",
196
196
  goal_profile_labels=None,
197
197
  goal_profile_weights=None,
198
+ heuristic_choice: str = "",
199
+ heuristic_reasoning: str = "",
200
+ critique_payload=None,
201
+ decision_mode: str = "heuristic",
198
202
  selected_choice: str = "",
199
203
  selection_reason: str = "",
200
204
  selection_source: str = "recommended",
@@ -206,8 +210,9 @@ def create_cortex_evaluation(
206
210
  session_id, task_id, goal, task_type, area, impact_level, context_hint,
207
211
  alternatives, scores, recommended_choice, recommended_reasoning, linked_outcome_id,
208
212
  goal_profile_id, goal_profile_labels, goal_profile_weights,
213
+ heuristic_choice, heuristic_reasoning, critique_payload, decision_mode,
209
214
  selected_choice, selection_reason, selection_source
210
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
215
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
211
216
  (
212
217
  session_id.strip(),
213
218
  task_id.strip(),
@@ -224,6 +229,10 @@ def create_cortex_evaluation(
224
229
  goal_profile_id.strip(),
225
230
  _as_json(goal_profile_labels or []),
226
231
  _as_json(goal_profile_weights or {}),
232
+ (heuristic_choice or recommended_choice).strip(),
233
+ (heuristic_reasoning or recommended_reasoning).strip(),
234
+ _as_json(critique_payload or {}),
235
+ (decision_mode or "heuristic").strip(),
227
236
  (selected_choice or recommended_choice).strip(),
228
237
  (selection_reason or recommended_reasoning).strip(),
229
238
  (selection_source or "recommended").strip(),
package/src/db/_schema.py CHANGED
@@ -909,6 +909,20 @@ def _m38_evolution_log_proposal_payload(conn):
909
909
  _migrate_add_column(conn, "evolution_log", "proposal_payload", "TEXT DEFAULT NULL")
910
910
 
911
911
 
912
+ def _m55_cortex_critique_trace(conn):
913
+ """Persist heuristic-vs-LLM critique traces for Cortex decisions."""
914
+ # Some legacy/minimal runtimes have schema_migrations backfilled through
915
+ # v48 without the optional Cortex table present. Repair the dependency
916
+ # before adding v55 columns so update never bricks those installs.
917
+ _m34_cortex_evaluations(conn)
918
+ _m35_cortex_evaluation_outcome_link(conn)
919
+ _m37_cortex_goal_profile_trace(conn)
920
+ _migrate_add_column(conn, "cortex_evaluations", "heuristic_choice", "TEXT DEFAULT ''")
921
+ _migrate_add_column(conn, "cortex_evaluations", "heuristic_reasoning", "TEXT DEFAULT ''")
922
+ _migrate_add_column(conn, "cortex_evaluations", "critique_payload", "TEXT DEFAULT '{}'")
923
+ _migrate_add_column(conn, "cortex_evaluations", "decision_mode", "TEXT DEFAULT 'heuristic'")
924
+
925
+
912
926
  def _m39_hook_runs(conn):
913
927
  """Persist hook lifecycle observability — closes Fase 3 item 7.
914
928
 
@@ -1479,6 +1493,7 @@ MIGRATIONS = [
1479
1493
  (52, "lifecycle_canonical_plan", _m52_lifecycle_canonical_plan),
1480
1494
  (53, "session_conversation_identity", _m53_session_conversation_identity),
1481
1495
  (54, "continuity_snapshots", _m54_continuity_snapshots),
1496
+ (55, "cortex_critique_trace", _m55_cortex_critique_trace),
1482
1497
  ]
1483
1498
 
1484
1499
 
@@ -0,0 +1,113 @@
1
+ {
2
+ "version": 1,
3
+ "models": [
4
+ {
5
+ "name": "bge-base-embeddings",
6
+ "kind": "fastembed_embedding",
7
+ "model_id": "BAAI/bge-base-en-v1.5",
8
+ "source_repo": "qdrant/bge-base-en-v1.5-onnx-q",
9
+ "revision": "738cad1c108e2f23649db9e44b2eab988626493b",
10
+ "model_file": "model_optimized.onnx",
11
+ "source": "src/cognitive/_core.py",
12
+ "required_files": [
13
+ {
14
+ "path": "config.json",
15
+ "size": 740,
16
+ "sha256": "86f84a5285de7f1ee673f712387219ef1e261ec27dcd870e793a80f9da1aaa3b"
17
+ },
18
+ {
19
+ "path": "model_optimized.onnx",
20
+ "size": 217824172,
21
+ "sha256": "4e556722bc4f65716c544c8a931f1e90fb3f866e5741fd93a96f051d673339c7"
22
+ },
23
+ {
24
+ "path": "special_tokens_map.json",
25
+ "size": 695,
26
+ "sha256": "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a"
27
+ },
28
+ {
29
+ "path": "tokenizer.json",
30
+ "size": 711396,
31
+ "sha256": "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
32
+ },
33
+ {
34
+ "path": "tokenizer_config.json",
35
+ "size": 1242,
36
+ "sha256": "0b29c7bfc889e53b36d9dd3e686dd4300f6525110eaa98c76a5dafceb2029f53"
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "name": "bge-small-embeddings",
42
+ "kind": "fastembed_embedding",
43
+ "model_id": "BAAI/bge-small-en-v1.5",
44
+ "source_repo": "qdrant/bge-small-en-v1.5-onnx-q",
45
+ "revision": "52398278842ec682c6f32300af41344b1c0b0bb2",
46
+ "model_file": "model_optimized.onnx",
47
+ "source": "src/migrate_embeddings.py",
48
+ "required_files": [
49
+ {
50
+ "path": "config.json",
51
+ "size": 706,
52
+ "sha256": "13582bcf2effc85b7bf3d3f5532e686bc1c9ce86bb009d10f0ec33cbe92299dd"
53
+ },
54
+ {
55
+ "path": "model_optimized.onnx",
56
+ "size": 66465124,
57
+ "sha256": "51f1bd0addd6e859e42c2c8021a5e5461385bb676a649f4b269aa445449f2431"
58
+ },
59
+ {
60
+ "path": "special_tokens_map.json",
61
+ "size": 695,
62
+ "sha256": "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a"
63
+ },
64
+ {
65
+ "path": "tokenizer.json",
66
+ "size": 711396,
67
+ "sha256": "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
68
+ },
69
+ {
70
+ "path": "tokenizer_config.json",
71
+ "size": 1242,
72
+ "sha256": "0b29c7bfc889e53b36d9dd3e686dd4300f6525110eaa98c76a5dafceb2029f53"
73
+ }
74
+ ]
75
+ },
76
+ {
77
+ "name": "cross-encoder-reranker",
78
+ "kind": "fastembed_reranker",
79
+ "model_id": "Xenova/ms-marco-MiniLM-L-6-v2",
80
+ "source_repo": "Xenova/ms-marco-MiniLM-L-6-v2",
81
+ "revision": "a09144355adeed5f58c8ed011d209bf8ee5a1fec",
82
+ "model_file": "onnx/model.onnx",
83
+ "source": "src/cognitive/_core.py",
84
+ "required_files": [
85
+ {
86
+ "path": "config.json",
87
+ "size": 824,
88
+ "sha256": "d827779a72d27ae68cf878a6fc2e954542663fe21ca515d9f4783fc96be2d37e"
89
+ },
90
+ {
91
+ "path": "onnx/model.onnx",
92
+ "size": 90992115,
93
+ "sha256": "c623d0bcb99f4622beb413eaef00cfbe5db20df9f1dd982da4b4f26022881870"
94
+ },
95
+ {
96
+ "path": "special_tokens_map.json",
97
+ "size": 125,
98
+ "sha256": "b6d346be366a7d1d48332dbc9fdf3bf8960b5d879522b7799ddba59e76237ee3"
99
+ },
100
+ {
101
+ "path": "tokenizer.json",
102
+ "size": 711396,
103
+ "sha256": "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
104
+ },
105
+ {
106
+ "path": "tokenizer_config.json",
107
+ "size": 1242,
108
+ "sha256": "0b29c7bfc889e53b36d9dd3e686dd4300f6525110eaa98c76a5dafceb2029f53"
109
+ }
110
+ ]
111
+ }
112
+ ]
113
+ }
@@ -0,0 +1,247 @@
1
+ """Pinned local model management for Brain embeddings + reranker.
2
+
3
+ FastEmbed's built-in registry resolves supported models by friendly name, but
4
+ its downloader tracks the current upstream repo head unless the caller builds a
5
+ stronger contract around it. This module provides that stronger contract:
6
+
7
+ - source repo pinned by immutable revision SHA
8
+ - required files + sha256 checksums stored in-repo
9
+ - deterministic materialization under ``~/.nexo/runtime/models``
10
+ - FastEmbed instantiated via ``specific_model_path`` so runtime loads the exact
11
+ downloaded artifacts instead of following a floating registry download
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import shutil
21
+ import tempfile
22
+ from dataclasses import dataclass
23
+ from functools import lru_cache
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import paths
28
+
29
+
30
+ MANIFEST_PATH = Path(__file__).resolve().with_name("local_model_manifest.json")
31
+ MODEL_LOCK_FILENAME = ".nexo-model-lock.json"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LocalModelFile:
36
+ path: str
37
+ size: int
38
+ sha256: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class LocalModelSpec:
43
+ name: str
44
+ kind: str
45
+ model_id: str
46
+ source_repo: str
47
+ revision: str
48
+ model_file: str
49
+ source: str
50
+ required_files: tuple[LocalModelFile, ...]
51
+
52
+
53
+ def _slugify(value: str) -> str:
54
+ return re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
55
+
56
+
57
+ def _hash_file(path: Path) -> str:
58
+ digest = hashlib.sha256()
59
+ with path.open("rb") as handle:
60
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
61
+ digest.update(chunk)
62
+ return digest.hexdigest()
63
+
64
+
65
+ def _lock_payload(spec: LocalModelSpec) -> dict[str, Any]:
66
+ return {
67
+ "name": spec.name,
68
+ "kind": spec.kind,
69
+ "model_id": spec.model_id,
70
+ "source_repo": spec.source_repo,
71
+ "revision": spec.revision,
72
+ "model_file": spec.model_file,
73
+ "required_files": [
74
+ {"path": item.path, "size": item.size, "sha256": item.sha256}
75
+ for item in spec.required_files
76
+ ],
77
+ }
78
+
79
+
80
+ @lru_cache(maxsize=1)
81
+ def _load_manifest() -> dict[str, LocalModelSpec]:
82
+ payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
83
+ specs: dict[str, LocalModelSpec] = {}
84
+ for raw in payload.get("models", []) or []:
85
+ files = tuple(LocalModelFile(**item) for item in raw.get("required_files", []) or [])
86
+ spec = LocalModelSpec(
87
+ name=str(raw["name"]),
88
+ kind=str(raw["kind"]),
89
+ model_id=str(raw["model_id"]),
90
+ source_repo=str(raw["source_repo"]),
91
+ revision=str(raw["revision"]),
92
+ model_file=str(raw["model_file"]),
93
+ source=str(raw["source"]),
94
+ required_files=files,
95
+ )
96
+ specs[spec.name] = spec
97
+ return specs
98
+
99
+
100
+ def get_local_model_spec(name: str) -> LocalModelSpec:
101
+ try:
102
+ return _load_manifest()[name]
103
+ except KeyError as exc: # pragma: no cover - defensive
104
+ raise KeyError(f"unknown local model spec: {name}") from exc
105
+
106
+
107
+ def list_local_model_specs(kind: str | None = None) -> list[LocalModelSpec]:
108
+ specs = list(_load_manifest().values())
109
+ if kind:
110
+ specs = [spec for spec in specs if spec.kind == kind]
111
+ return specs
112
+
113
+
114
+ def models_dir() -> Path:
115
+ root = paths.models_dir()
116
+ root.mkdir(parents=True, exist_ok=True)
117
+ return root
118
+
119
+
120
+ def managed_model_dir(spec: LocalModelSpec) -> Path:
121
+ return models_dir() / _slugify(spec.name) / spec.revision
122
+
123
+
124
+ def verify_local_model_dir(spec: LocalModelSpec, root: Path | None = None) -> dict[str, Any]:
125
+ target = root or managed_model_dir(spec)
126
+ problems: list[str] = []
127
+ if not target.exists():
128
+ problems.append("missing directory")
129
+ for file_spec in spec.required_files:
130
+ file_path = target / file_spec.path
131
+ if not file_path.exists():
132
+ problems.append(f"missing file:{file_spec.path}")
133
+ continue
134
+ size = file_path.stat().st_size
135
+ if size != file_spec.size:
136
+ problems.append(f"size mismatch:{file_spec.path}:{size}!={file_spec.size}")
137
+ continue
138
+ actual_hash = _hash_file(file_path)
139
+ if actual_hash != file_spec.sha256:
140
+ problems.append(f"sha256 mismatch:{file_spec.path}:{actual_hash}!={file_spec.sha256}")
141
+ lock_path = target / MODEL_LOCK_FILENAME
142
+ if lock_path.exists():
143
+ try:
144
+ payload = json.loads(lock_path.read_text(encoding="utf-8"))
145
+ if payload.get("revision") != spec.revision or payload.get("source_repo") != spec.source_repo:
146
+ problems.append("lock metadata mismatch")
147
+ except Exception:
148
+ problems.append("invalid lock metadata")
149
+ return {"ok": not problems, "path": str(target), "problems": problems}
150
+
151
+
152
+ def _copy_required_files(snapshot_dir: Path, target_dir: Path, spec: LocalModelSpec) -> None:
153
+ for file_spec in spec.required_files:
154
+ source_path = snapshot_dir / file_spec.path
155
+ if not source_path.exists():
156
+ raise FileNotFoundError(f"snapshot missing required file: {file_spec.path}")
157
+ destination = target_dir / file_spec.path
158
+ destination.parent.mkdir(parents=True, exist_ok=True)
159
+ shutil.copy2(source_path, destination)
160
+ (target_dir / MODEL_LOCK_FILENAME).write_text(
161
+ json.dumps(_lock_payload(spec), indent=2, ensure_ascii=False) + "\n",
162
+ encoding="utf-8",
163
+ )
164
+
165
+
166
+ def ensure_local_model(
167
+ name: str,
168
+ *,
169
+ local_files_only: bool = False,
170
+ force_redownload: bool = False,
171
+ ) -> Path:
172
+ spec = get_local_model_spec(name)
173
+ target_dir = managed_model_dir(spec)
174
+ verification = verify_local_model_dir(spec, target_dir)
175
+ if verification["ok"] and not force_redownload:
176
+ return target_dir
177
+ if target_dir.exists():
178
+ shutil.rmtree(target_dir, ignore_errors=True)
179
+
180
+ from huggingface_hub import snapshot_download
181
+
182
+ cache_dir = models_dir() / "_hf-cache"
183
+ cache_dir.mkdir(parents=True, exist_ok=True)
184
+ snapshot_dir = Path(
185
+ snapshot_download(
186
+ repo_id=spec.source_repo,
187
+ revision=spec.revision,
188
+ allow_patterns=[item.path for item in spec.required_files],
189
+ cache_dir=str(cache_dir),
190
+ local_files_only=local_files_only,
191
+ )
192
+ )
193
+
194
+ target_parent = target_dir.parent
195
+ target_parent.mkdir(parents=True, exist_ok=True)
196
+ tmp_dir = Path(
197
+ tempfile.mkdtemp(
198
+ prefix=f".{_slugify(spec.name)}-{spec.revision[:12]}.",
199
+ dir=str(target_parent),
200
+ )
201
+ )
202
+ try:
203
+ _copy_required_files(snapshot_dir, tmp_dir, spec)
204
+ verification = verify_local_model_dir(spec, tmp_dir)
205
+ if not verification["ok"]:
206
+ raise ValueError("; ".join(verification["problems"]))
207
+ os.replace(tmp_dir, target_dir)
208
+ except Exception:
209
+ shutil.rmtree(tmp_dir, ignore_errors=True)
210
+ raise
211
+ return target_dir
212
+
213
+
214
+ def build_fastembed_embedding(name: str):
215
+ spec = get_local_model_spec(name)
216
+ if spec.kind != "fastembed_embedding":
217
+ raise ValueError(f"{name} is not a fastembed embedding model")
218
+ from fastembed import TextEmbedding
219
+
220
+ target_dir = ensure_local_model(name)
221
+ return TextEmbedding(spec.model_id, specific_model_path=str(target_dir))
222
+
223
+
224
+ def build_fastembed_reranker(name: str):
225
+ spec = get_local_model_spec(name)
226
+ if spec.kind != "fastembed_reranker":
227
+ raise ValueError(f"{name} is not a fastembed reranker")
228
+ from fastembed.rerank.cross_encoder import TextCrossEncoder
229
+
230
+ target_dir = ensure_local_model(name)
231
+ return TextCrossEncoder(spec.model_id, specific_model_path=str(target_dir))
232
+
233
+
234
+ __all__ = [
235
+ "LocalModelFile",
236
+ "LocalModelSpec",
237
+ "MODEL_LOCK_FILENAME",
238
+ "MANIFEST_PATH",
239
+ "build_fastembed_embedding",
240
+ "build_fastembed_reranker",
241
+ "ensure_local_model",
242
+ "get_local_model_spec",
243
+ "list_local_model_specs",
244
+ "managed_model_dir",
245
+ "models_dir",
246
+ "verify_local_model_dir",
247
+ ]
@@ -16,6 +16,7 @@ import time
16
16
  import numpy as np
17
17
 
18
18
  import paths
19
+ from local_models import build_fastembed_embedding, get_local_model_spec
19
20
 
20
21
  NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
21
22
  _cognitive_dir = paths.cognitive_dir()
@@ -24,8 +25,8 @@ DB_PATH = str(_cognitive_dir / "cognitive.db")
24
25
  BACKUP_PATH = DB_PATH + ".bak-384dims-pre-upgrade"
25
26
 
26
27
  MODELS = {
27
- "small": ("BAAI/bge-small-en-v1.5", 384),
28
- "base": ("BAAI/bge-base-en-v1.5", 768),
28
+ "small": ("bge-small-embeddings", 384),
29
+ "base": ("bge-base-embeddings", 768),
29
30
  }
30
31
 
31
32
 
@@ -47,8 +48,6 @@ def verify():
47
48
 
48
49
  def upgrade():
49
50
  """Re-embed all memories from bge-small (384) to bge-base (768)."""
50
- from fastembed import TextEmbedding
51
-
52
51
  # Verify current state
53
52
  print("Current state:")
54
53
  verify()
@@ -62,8 +61,9 @@ def upgrade():
62
61
 
63
62
  # Load new model
64
63
  model_name, expected_dim = MODELS["base"]
65
- print(f"\nLoading {model_name}...")
66
- model = TextEmbedding(model_name)
64
+ spec = get_local_model_spec(model_name)
65
+ print(f"\nLoading {spec.model_id}@{spec.revision}...")
66
+ model = build_fastembed_embedding(model_name)
67
67
 
68
68
  conn = sqlite3.connect(DB_PATH)
69
69
  try:
@@ -28,40 +28,37 @@ class WarmupTarget:
28
28
  kind: str
29
29
  model_id: str
30
30
  source: str
31
+ source_repo: str | None = None
31
32
  revision: str | None = None
32
33
  required: bool = True
33
34
 
34
35
 
35
36
  def warmup_targets() -> list[WarmupTarget]:
36
37
  from classifier_local import MODEL_ID, MODEL_REVISION
38
+ from local_models import list_local_model_specs
37
39
 
38
- return [
40
+ targets = [
39
41
  WarmupTarget(
40
42
  name="local-zero-shot-classifier",
41
43
  kind="transformers_sequence_classifier",
42
44
  model_id=MODEL_ID,
43
45
  revision=MODEL_REVISION,
44
46
  source="src/classifier_local.py",
45
- ),
46
- WarmupTarget(
47
- name="bge-base-embeddings",
48
- kind="fastembed_embedding",
49
- model_id="BAAI/bge-base-en-v1.5",
50
- source="src/cognitive/_core.py",
51
- ),
52
- WarmupTarget(
53
- name="bge-small-embeddings",
54
- kind="fastembed_embedding",
55
- model_id="BAAI/bge-small-en-v1.5",
56
- source="src/migrate_embeddings.py",
57
- ),
58
- WarmupTarget(
59
- name="cross-encoder-reranker",
60
- kind="fastembed_reranker",
61
- model_id="Xenova/ms-marco-MiniLM-L-6-v2",
62
- source="src/cognitive/_core.py",
47
+ source_repo=MODEL_ID,
63
48
  ),
64
49
  ]
50
+ for spec in list_local_model_specs():
51
+ targets.append(
52
+ WarmupTarget(
53
+ name=spec.name,
54
+ kind=spec.kind,
55
+ model_id=spec.model_id,
56
+ revision=spec.revision,
57
+ source=spec.source,
58
+ source_repo=spec.source_repo,
59
+ )
60
+ )
61
+ return targets
65
62
 
66
63
 
67
64
  def _state_path() -> Path:
@@ -86,16 +83,16 @@ def _warm_transformers(target: WarmupTarget) -> None:
86
83
 
87
84
 
88
85
  def _warm_fastembed_embedding(target: WarmupTarget) -> None:
89
- from fastembed import TextEmbedding
86
+ from local_models import build_fastembed_embedding
90
87
 
91
- model = TextEmbedding(target.model_id)
88
+ model = build_fastembed_embedding(target.name)
92
89
  list(model.embed(["NEXO model warmup"]))
93
90
 
94
91
 
95
92
  def _warm_fastembed_reranker(target: WarmupTarget) -> None:
96
- from fastembed.rerank.cross_encoder import TextCrossEncoder
93
+ from local_models import build_fastembed_reranker
97
94
 
98
- TextCrossEncoder(target.model_id)
95
+ build_fastembed_reranker(target.name)
99
96
 
100
97
 
101
98
  def warm_target(target: WarmupTarget) -> None:
package/src/paths.py CHANGED
@@ -336,6 +336,14 @@ def cognitive_dir() -> Path:
336
336
  return new
337
337
 
338
338
 
339
+ def models_dir() -> Path:
340
+ new = runtime_dir() / "models"
341
+ legacy = home() / "models"
342
+ if not new.exists() and legacy.exists():
343
+ return legacy
344
+ return new
345
+
346
+
339
347
  def coordination_dir() -> Path:
340
348
  new = runtime_dir() / "coordination"
341
349
  legacy = home() / "coordination"
@@ -488,6 +496,7 @@ __all__ = [
488
496
  "backups_dir",
489
497
  "memory_dir",
490
498
  "cognitive_dir",
499
+ "models_dir",
491
500
  "coordination_dir",
492
501
  "exports_dir",
493
502
  "nexo_email_dir",
@@ -23,6 +23,7 @@ from datetime import datetime, timedelta
23
23
  from pathlib import Path
24
24
 
25
25
  from db import VALID_IMPACT_LEVELS, VALID_TASK_TYPES, validate_impact_level, validate_task_type
26
+ from db._semantic_similarity import hybrid_similarity_score
26
27
 
27
28
 
28
29
  def _get_db():
@@ -89,6 +90,10 @@ STOP_WORDS = {
89
90
  }
90
91
  HISTORICAL_OUTCOME_MIN_RESOLVED = 2
91
92
  HISTORICAL_OUTCOME_LOOKBACK = 12
93
+ SEMANTIC_HISTORY_LOOKBACK = 24
94
+ SEMANTIC_HISTORY_MATCH_THRESHOLD = 0.58
95
+ CRITIQUE_TOP_CANDIDATES = 3
96
+ CRITIQUE_MAX_MARGIN = 0.45
92
97
 
93
98
 
94
99
  def _term_hits(text: str, terms: set[str]) -> int:
@@ -279,50 +284,80 @@ def _constraint_penalty(text: str, constraints: list[str]) -> tuple[float, list[
279
284
 
280
285
  def _history_signal(text: str, *, area: str = "", goal: str = "") -> dict:
281
286
  conn = _get_db()
282
- tokens = _tokenize(" ".join(part for part in [text, area, goal] if part), limit=6)
283
- if not tokens:
287
+ query_text = " ".join(part for part in [text, area, goal] if part).strip()
288
+ if not query_text:
284
289
  return {"positive": 0.0, "negative": 0.0, "matched_decisions": 0, "matched_outcomes": 0}
285
290
 
286
- decision_positive = 0
287
- decision_negative = 0
291
+ def _keyword_extractor(value: str) -> list[str]:
292
+ return _tokenize(value, limit=8)
293
+
294
+ decision_positive = 0.0
295
+ decision_negative = 0.0
288
296
  matched_decisions = 0
289
- for token in tokens[:3]:
297
+ if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='decisions'").fetchone():
290
298
  rows = conn.execute(
291
- """SELECT outcome FROM decisions
292
- WHERE lower(decision) LIKE ? OR lower(alternatives) LIKE ? OR lower(based_on) LIKE ?
293
- ORDER BY created_at DESC LIMIT 6""",
294
- tuple(f"%{token}%" for _ in range(3)),
299
+ """SELECT decision, alternatives, based_on, outcome
300
+ FROM decisions
301
+ ORDER BY created_at DESC LIMIT ?""",
302
+ (SEMANTIC_HISTORY_LOOKBACK,),
295
303
  ).fetchall()
296
304
  for row in rows:
305
+ candidate_text = " ".join(
306
+ str(row[key] or "")
307
+ for key in ("decision", "alternatives", "based_on")
308
+ ).strip()
309
+ similarity = hybrid_similarity_score(
310
+ query_text,
311
+ candidate_text,
312
+ keyword_extractor=_keyword_extractor,
313
+ strong_semantic_threshold=0.82,
314
+ moderate_semantic_threshold=0.74,
315
+ moderate_keyword_floor=0.12,
316
+ )
317
+ if similarity < SEMANTIC_HISTORY_MATCH_THRESHOLD:
318
+ continue
297
319
  matched_decisions += 1
298
320
  outcome = (row["outcome"] or "").lower()
299
321
  if _contains_any(outcome, NEGATIVE_OUTCOME_TERMS):
300
- decision_negative += 1
322
+ decision_negative += min(1.0, similarity)
301
323
  elif _contains_any(outcome, POSITIVE_OUTCOME_TERMS):
302
- decision_positive += 1
324
+ decision_positive += min(1.0, similarity)
303
325
 
304
- outcome_positive = 0
305
- outcome_negative = 0
326
+ outcome_positive = 0.0
327
+ outcome_negative = 0.0
306
328
  matched_outcomes = 0
307
329
  if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='outcomes'").fetchone():
308
- for token in tokens[:3]:
309
- rows = conn.execute(
310
- """SELECT status FROM outcomes
311
- WHERE lower(description) LIKE ? OR lower(expected_result) LIKE ? OR lower(action_type) LIKE ?
312
- ORDER BY created_at DESC LIMIT 6""",
313
- tuple(f"%{token}%" for _ in range(3)),
314
- ).fetchall()
315
- for row in rows:
316
- matched_outcomes += 1
317
- status = (row["status"] or "").lower()
318
- if status == "met":
319
- outcome_positive += 1
320
- elif status in {"missed", "expired"}:
321
- outcome_negative += 1
330
+ rows = conn.execute(
331
+ """SELECT description, expected_result, action_type, status
332
+ FROM outcomes
333
+ ORDER BY created_at DESC LIMIT ?""",
334
+ (SEMANTIC_HISTORY_LOOKBACK,),
335
+ ).fetchall()
336
+ for row in rows:
337
+ candidate_text = " ".join(
338
+ str(row[key] or "")
339
+ for key in ("description", "expected_result", "action_type")
340
+ ).strip()
341
+ similarity = hybrid_similarity_score(
342
+ query_text,
343
+ candidate_text,
344
+ keyword_extractor=_keyword_extractor,
345
+ strong_semantic_threshold=0.82,
346
+ moderate_semantic_threshold=0.74,
347
+ moderate_keyword_floor=0.12,
348
+ )
349
+ if similarity < SEMANTIC_HISTORY_MATCH_THRESHOLD:
350
+ continue
351
+ matched_outcomes += 1
352
+ status = (row["status"] or "").lower()
353
+ if status == "met":
354
+ outcome_positive += min(1.0, similarity)
355
+ elif status in {"missed", "expired"}:
356
+ outcome_negative += min(1.0, similarity)
322
357
 
323
358
  return {
324
- "positive": min(2.5, (decision_positive * 0.4) + (outcome_positive * 0.5)),
325
- "negative": min(3.0, (decision_negative * 0.6) + (outcome_negative * 0.7)),
359
+ "positive": round(min(2.5, (decision_positive * 0.9) + (outcome_positive * 1.0)), 2),
360
+ "negative": round(min(3.0, (decision_negative * 1.1) + (outcome_negative * 1.2)), 2),
326
361
  "matched_decisions": matched_decisions,
327
362
  "matched_outcomes": matched_outcomes,
328
363
  }
@@ -702,6 +737,172 @@ def _format_decision_summary(recommended: dict, alternatives_scored: list[dict])
702
737
  return f"Recomendada por el mejor balance entre impacto, éxito, riesgo y huella somática; {notes}."
703
738
 
704
739
 
740
+ def _parse_json_object_response(raw: str) -> dict:
741
+ text = (raw or "").strip()
742
+ if not text:
743
+ return {}
744
+ try:
745
+ parsed = json.loads(text)
746
+ return parsed if isinstance(parsed, dict) else {}
747
+ except json.JSONDecodeError:
748
+ match = re.search(r"\{.*\}", text, re.DOTALL)
749
+ if not match:
750
+ return {}
751
+ try:
752
+ parsed = json.loads(match.group(0))
753
+ return parsed if isinstance(parsed, dict) else {}
754
+ except json.JSONDecodeError:
755
+ return {}
756
+
757
+
758
+ def _critique_tier(
759
+ *,
760
+ impact_level: str,
761
+ scored: list[dict],
762
+ constraints: list[str],
763
+ evidence_refs: list[str],
764
+ ) -> str:
765
+ if impact_level != "critical":
766
+ return "alto"
767
+ gap = 99.0
768
+ if len(scored) > 1:
769
+ gap = scored[0]["total_score"] - scored[1]["total_score"]
770
+ if gap <= CRITIQUE_MAX_MARGIN or len(constraints) >= 3 or len(evidence_refs) <= 1:
771
+ return "maximo"
772
+ return "alto"
773
+
774
+
775
+ def _run_llm_critique(
776
+ *,
777
+ goal: str,
778
+ task_type: str,
779
+ impact_level: str,
780
+ area: str,
781
+ context_hint: str,
782
+ constraints: list[str],
783
+ evidence_refs: list[str],
784
+ goal_profile: dict,
785
+ scored: list[dict],
786
+ ) -> dict:
787
+ if impact_level not in {"high", "critical"} or len(scored) < 2:
788
+ return {"active": False}
789
+
790
+ try:
791
+ from call_model_raw import call_model_raw, ClassifierUnavailableError
792
+ from core_prompts import render_core_prompt
793
+ from operator_language import append_operator_language_contract
794
+ except Exception as exc:
795
+ return {"active": True, "ok": False, "error": f"critic_unavailable:{exc}"}
796
+
797
+ tier = _critique_tier(
798
+ impact_level=impact_level,
799
+ scored=scored,
800
+ constraints=constraints,
801
+ evidence_refs=evidence_refs,
802
+ )
803
+ payload = {
804
+ "goal": goal,
805
+ "task_type": task_type,
806
+ "impact_level": impact_level,
807
+ "area": area,
808
+ "context_hint": context_hint,
809
+ "constraints": constraints,
810
+ "evidence_refs": evidence_refs,
811
+ "goal_profile": {
812
+ "profile_id": goal_profile.get("profile_id", ""),
813
+ "profile_name": goal_profile.get("profile_name", ""),
814
+ "goal_labels": goal_profile.get("goal_labels", []),
815
+ "weights": goal_profile.get("weights", {}),
816
+ },
817
+ "heuristic_recommendation": scored[0]["name"],
818
+ "candidates": [
819
+ {
820
+ "name": item["name"],
821
+ "impact": item["impact"],
822
+ "success_probability": item["success_probability"],
823
+ "risk_level": item["risk_level"],
824
+ "somatic_penalty": item["somatic_penalty"],
825
+ "total_score": item["total_score"],
826
+ "notes": item.get("notes") or [],
827
+ "historical_signal": item.get("historical_signal") or {},
828
+ "pattern_learning_signal": item.get("pattern_learning_signal") or {},
829
+ }
830
+ for item in scored[:CRITIQUE_TOP_CANDIDATES]
831
+ ],
832
+ }
833
+ prompt = render_core_prompt(
834
+ "cortex-decision-critic",
835
+ payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
836
+ )
837
+ prompt = append_operator_language_contract(prompt)
838
+ try:
839
+ raw = call_model_raw(
840
+ prompt,
841
+ caller="cortex_decision_critic",
842
+ tier=tier,
843
+ system=render_core_prompt("json-object-only"),
844
+ max_tokens=500,
845
+ temperature=0.0,
846
+ stop_sequences=[],
847
+ timeout=20.0,
848
+ )
849
+ except ClassifierUnavailableError as exc:
850
+ return {"active": True, "ok": False, "tier": tier, "error": str(exc)}
851
+
852
+ parsed = _parse_json_object_response(raw)
853
+ candidate_names = [item["name"] for item in scored]
854
+ recommended_choice = str(parsed.get("recommended_choice") or "").strip()
855
+ if recommended_choice not in candidate_names:
856
+ return {
857
+ "active": True,
858
+ "ok": False,
859
+ "tier": tier,
860
+ "error": "invalid_recommended_choice",
861
+ "raw_response": raw[:1200],
862
+ }
863
+
864
+ ranking = parsed.get("confirmed_ranking")
865
+ clean_ranking: list[str] = []
866
+ if isinstance(ranking, list):
867
+ for item in ranking:
868
+ name = str(item or "").strip()
869
+ if name in candidate_names and name not in clean_ranking:
870
+ clean_ranking.append(name)
871
+ for name in candidate_names:
872
+ if name not in clean_ranking:
873
+ clean_ranking.append(name)
874
+
875
+ try:
876
+ confidence = float(parsed.get("confidence"))
877
+ except (TypeError, ValueError):
878
+ confidence = 0.0
879
+ confidence = max(0.0, min(1.0, confidence))
880
+ risk_flags = parsed.get("risk_flags")
881
+ if not isinstance(risk_flags, list):
882
+ risk_flags = []
883
+ reasoning_summary = str(parsed.get("reasoning_summary") or "").strip()
884
+ disagreement = bool(parsed.get("disagreement_with_heuristic"))
885
+ return {
886
+ "active": True,
887
+ "ok": True,
888
+ "tier": tier,
889
+ "recommended_choice": recommended_choice,
890
+ "confirmed_ranking": clean_ranking,
891
+ "confidence": round(confidence, 3),
892
+ "risk_flags": [str(item).strip() for item in risk_flags if str(item).strip()][:5],
893
+ "reasoning_summary": reasoning_summary,
894
+ "disagreement_with_heuristic": disagreement or (recommended_choice != scored[0]["name"]),
895
+ }
896
+
897
+
898
+ def _reorder_scores_by_names(scored: list[dict], ranking: list[str]) -> list[dict]:
899
+ order = {name: idx for idx, name in enumerate(ranking)}
900
+ return sorted(
901
+ scored,
902
+ key=lambda item: (order.get(item["name"], len(order)), -item["total_score"]),
903
+ )
904
+
905
+
705
906
  def handle_cortex_check(
706
907
  goal: str,
707
908
  task_type: str = "answer",
@@ -858,6 +1059,7 @@ def handle_cortex_decide(
858
1059
  linked_outcome_id: int = 0,
859
1060
  goal_profile_id: str = "",
860
1061
  goal_id: str = "",
1062
+ auto_create_outcome: bool = False,
861
1063
  ) -> str:
862
1064
  """Evaluate concrete alternatives for a high-impact task using the existing Cortex."""
863
1065
  clean_goal = (goal or "").strip()
@@ -927,16 +1129,39 @@ def handle_cortex_decide(
927
1129
  for item in parsed_alternatives
928
1130
  ]
929
1131
  scored.sort(key=lambda item: item["total_score"], reverse=True)
930
- recommended = scored[0]
931
- reasoning = _format_decision_summary(recommended, scored)
1132
+ heuristic_recommended = scored[0]
1133
+ heuristic_reasoning = _format_decision_summary(heuristic_recommended, scored)
1134
+ critique = _run_llm_critique(
1135
+ goal=clean_goal,
1136
+ task_type=clean_type,
1137
+ impact_level=clean_level,
1138
+ area=area.strip(),
1139
+ context_hint=context_hint.strip(),
1140
+ constraints=parsed_constraints,
1141
+ evidence_refs=parsed_evidence,
1142
+ goal_profile=resolved_goal_profile,
1143
+ scored=scored,
1144
+ )
1145
+ decision_mode = "heuristic"
1146
+ if critique.get("ok"):
1147
+ scored = _reorder_scores_by_names(scored, critique.get("confirmed_ranking") or [])
1148
+ recommended = next(
1149
+ (item for item in scored if item["name"] == critique["recommended_choice"]),
1150
+ heuristic_recommended,
1151
+ )
1152
+ reasoning = (critique.get("reasoning_summary") or "").strip() or heuristic_reasoning
1153
+ decision_mode = "heuristic_plus_llm"
1154
+ else:
1155
+ recommended = heuristic_recommended
1156
+ reasoning = heuristic_reasoning
932
1157
  resolved_outcome_id = _resolve_linked_outcome_id(
933
1158
  linked_outcome_id=linked_outcome_id,
934
1159
  task_id=task_id,
935
1160
  )
936
1161
 
937
- # Auto-create outcome when none exists, so cortex decisions
938
- # get verified by outcome-checker and close the feedback loop.
939
- if resolved_outcome_id is None and clean_goal and task_id:
1162
+ # Outcome auto-creation is opt-in so analytics can distinguish
1163
+ # persisted decisions from explicitly tracked outcomes.
1164
+ if auto_create_outcome and resolved_outcome_id is None and clean_goal and task_id:
940
1165
  try:
941
1166
  from db import create_outcome
942
1167
 
@@ -974,6 +1199,10 @@ def handle_cortex_decide(
974
1199
  goal_profile_id=resolved_goal_profile.get("profile_id", ""),
975
1200
  goal_profile_labels=resolved_goal_profile.get("goal_labels", []),
976
1201
  goal_profile_weights=resolved_goal_profile.get("weights", {}),
1202
+ heuristic_choice=heuristic_recommended["name"],
1203
+ heuristic_reasoning=heuristic_reasoning,
1204
+ critique_payload=critique,
1205
+ decision_mode=decision_mode,
977
1206
  selected_choice=recommended["name"],
978
1207
  selection_reason=reasoning,
979
1208
  selection_source="recommended",
@@ -997,6 +1226,10 @@ def handle_cortex_decide(
997
1226
  "impact_level": clean_level,
998
1227
  "recommendation": recommended["name"],
999
1228
  "reasoning": reasoning,
1229
+ "heuristic_recommendation": heuristic_recommended["name"],
1230
+ "heuristic_reasoning": heuristic_reasoning,
1231
+ "decision_mode": decision_mode,
1232
+ "critique": critique,
1000
1233
  "selected_choice": record.get("selected_choice"),
1001
1234
  "selection_source": record.get("selection_source"),
1002
1235
  "linked_outcome_id": record.get("linked_outcome_id"),
@@ -219,6 +219,8 @@ SYSTEM_OWNED_CALLERS: dict[str, str] = {
219
219
  "learning_validator": "medio",
220
220
  "outcome_checker": "medio",
221
221
  "check_context": "medio",
222
+ "semantic_reasoner": "muy_bajo",
223
+ "cortex_decision_critic": "alto",
222
224
 
223
225
  # ---- Agent orchestration ----------------------------------------------
224
226
  "agent_run/generic": "alto",
@@ -16,7 +16,13 @@ import paths
16
16
  CONTINUITY_API_LEVEL = 1
17
17
  MCP_STATUS_SCHEMA_VERSION = 1
18
18
  PROCESS_VERSION = ""
19
+ RESTART_CLIENT_ACTIONS = {
20
+ "claude_desktop": "restart_client_required",
21
+ "claude_code": "restart_session_required",
22
+ "codex": "restart_session_required",
23
+ }
19
24
  RESTART_ALLOWLIST = {
25
+ "nexo_startup",
20
26
  "nexo_status",
21
27
  "nexo_system_catalog",
22
28
  "nexo_tool_explain",
@@ -48,6 +54,61 @@ def _write_json_atomic(path: Path, payload: dict) -> None:
48
54
  tmp.replace(path)
49
55
 
50
56
 
57
+ def _normalize_restart_client(value: str | None) -> str:
58
+ candidate = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
59
+ aliases = {
60
+ "claude": "claude_code",
61
+ "claudecode": "claude_code",
62
+ "claude_code": "claude_code",
63
+ "claude_desktop": "claude_desktop",
64
+ "claude_desktop_app": "claude_desktop",
65
+ "desktop": "claude_desktop",
66
+ "codex": "codex",
67
+ }
68
+ resolved = aliases.get(candidate, candidate)
69
+ if resolved in RESTART_CLIENT_ACTIONS:
70
+ return resolved
71
+ return ""
72
+
73
+
74
+ def _enabled_flag(value) -> bool:
75
+ if isinstance(value, str):
76
+ return value.strip().lower() not in {"", "0", "false", "no", "off", "disabled", "none"}
77
+ return bool(value)
78
+
79
+
80
+ def _restart_clients_from_preferences() -> dict[str, str]:
81
+ try:
82
+ from runtime_power import load_schedule_config
83
+
84
+ prefs = load_schedule_config()
85
+ except Exception:
86
+ prefs = {}
87
+
88
+ raw_clients = prefs.get("interactive_clients") if isinstance(prefs, dict) else {}
89
+ clients: dict[str, str] = {}
90
+ if isinstance(raw_clients, dict):
91
+ for raw_key, raw_enabled in raw_clients.items():
92
+ key = _normalize_restart_client(str(raw_key or ""))
93
+ if key and _enabled_flag(raw_enabled):
94
+ clients[key] = RESTART_CLIENT_ACTIONS[key]
95
+ return clients
96
+
97
+
98
+ def _restart_clients_for_marker(*, client: str = "") -> dict[str, str]:
99
+ explicit_client = _normalize_restart_client(client or os.environ.get("NEXO_MCP_CLIENT", ""))
100
+ if explicit_client:
101
+ return {explicit_client: RESTART_CLIENT_ACTIONS[explicit_client]}
102
+
103
+ clients = _restart_clients_from_preferences()
104
+ if clients:
105
+ return clients
106
+
107
+ # Safe default for fresh/legacy installs: Claude Code is the primary
108
+ # terminal client, and avoiding absent clients prevents permanent markers.
109
+ return {"claude_code": RESTART_CLIENT_ACTIONS["claude_code"]}
110
+
111
+
51
112
  def core_container_dir() -> Path:
52
113
  return paths.home() / "core"
53
114
 
@@ -135,6 +196,7 @@ def write_restart_required_marker(
135
196
  from_version: str,
136
197
  to_version: str,
137
198
  reason: str = "brain_update",
199
+ client: str = "",
138
200
  ) -> dict:
139
201
  path = restart_required_marker_path()
140
202
  payload = {
@@ -144,11 +206,7 @@ def write_restart_required_marker(
144
206
  "to_version": str(to_version or "").strip(),
145
207
  "reason": str(reason or "brain_update"),
146
208
  "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
147
- "clients": {
148
- "claude_desktop": "restart_client_required",
149
- "claude_code": "restart_session_required",
150
- "codex": "restart_session_required",
151
- },
209
+ "clients": _restart_clients_for_marker(client=client),
152
210
  }
153
211
  _write_json_atomic(path, payload)
154
212
  payload["path"] = str(path)
@@ -206,6 +264,7 @@ def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, vers
206
264
 
207
265
 
208
266
  def clear_restart_required_marker(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
267
+ client = _normalize_restart_client(client)
209
268
  path = restart_required_marker_path()
210
269
  marker = read_restart_required_marker()
211
270
  if not marker.get("required"):
@@ -244,6 +303,7 @@ def clear_restart_required_marker(*, client: str = "", installed_version: str =
244
303
 
245
304
 
246
305
  def resolve_restart_required(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
306
+ client = _normalize_restart_client(client)
247
307
  marker = read_restart_required_marker()
248
308
  installed = str(installed_version or installed_runtime_version() or "").strip()
249
309
  process = str(process_version or PROCESS_VERSION or installed).strip()
@@ -277,6 +337,7 @@ def resolve_restart_required(*, client: str = "", installed_version: str = "", p
277
337
 
278
338
 
279
339
  def build_mcp_status(*, client: str = "") -> dict:
340
+ client = _normalize_restart_client(client)
280
341
  state = resolve_restart_required(client=client)
281
342
  marker = state["marker"]
282
343
  return {
@@ -319,6 +380,28 @@ def prime_process_version() -> str:
319
380
  class RestartRequiredMiddleware(Middleware):
320
381
  client: str = ""
321
382
 
383
+ def __post_init__(self) -> None:
384
+ self.client = _normalize_restart_client(self.client)
385
+
386
+ def _ack_current_client_if_restarted(self, state: dict) -> dict:
387
+ if not self.client or not state.get("restart_required"):
388
+ return state
389
+ installed = str(state.get("installed_version") or "").strip()
390
+ process = str(state.get("process_version") or "").strip()
391
+ if not installed or not process or installed != process:
392
+ return state
393
+
394
+ clear_restart_required_marker(
395
+ client=self.client,
396
+ installed_version=installed,
397
+ process_version=process,
398
+ )
399
+ return resolve_restart_required(
400
+ client=self.client,
401
+ installed_version=installed,
402
+ process_version=process,
403
+ )
404
+
322
405
  async def _tool_result_for_restart_required(self, context, payload: dict) -> ToolResult:
323
406
  payload_text = json.dumps(payload, ensure_ascii=False)
324
407
  tool = None
@@ -344,6 +427,7 @@ class RestartRequiredMiddleware(Middleware):
344
427
  async def on_call_tool(self, context, call_next):
345
428
  tool_name = str(getattr(context.message, "name", "") or "").strip()
346
429
  state = resolve_restart_required(client=self.client)
430
+ state = self._ack_current_client_if_restarted(state)
347
431
  if not state["restart_required"] or tool_name in RESTART_ALLOWLIST:
348
432
  return await call_next(context)
349
433
 
@@ -145,8 +145,8 @@ def detect_duplicates(conn):
145
145
  """Find semantically similar learnings using fastembed."""
146
146
  ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
147
147
  try:
148
- from fastembed import TextEmbedding
149
148
  import numpy as np
149
+ from local_models import build_fastembed_embedding
150
150
  except ImportError:
151
151
  print(f"[{ts}] Dedup skipped: fastembed not available")
152
152
  return []
@@ -158,7 +158,7 @@ def detect_duplicates(conn):
158
158
  if len(learnings) < 2:
159
159
  return []
160
160
 
161
- model = TextEmbedding("BAAI/bge-base-en-v1.5")
161
+ model = build_fastembed_embedding("bge-base-embeddings")
162
162
  texts = [f"{l['title']}: {l['content'][:300]}" for l in learnings]
163
163
  embeddings = list(model.embed(texts))
164
164
  embeddings = np.array(embeddings)
@@ -0,0 +1,24 @@
1
+ You are NEXO Cortex critique mode for a high-stakes decision.
2
+
3
+ Review the heuristic ranking below. Do not invent facts, risks, or constraints that are not present in the payload.
4
+
5
+ Return exactly one JSON object with this shape:
6
+ {
7
+ "recommended_choice": "candidate_name",
8
+ "confirmed_ranking": ["candidate_name_1", "candidate_name_2"],
9
+ "confidence": 0.0,
10
+ "risk_flags": ["short string"],
11
+ "disagreement_with_heuristic": false,
12
+ "reasoning_summary": "short explanation"
13
+ }
14
+
15
+ Rules:
16
+ - `recommended_choice` MUST be one of the provided candidate names.
17
+ - `confirmed_ranking` MUST contain only provided candidate names, without duplicates.
18
+ - Prefer reversible, verifiable options when risk is high or evidence is thin.
19
+ - If evidence is insufficient to overturn the heuristic winner, keep the heuristic winner.
20
+ - Use `risk_flags` for concrete concerns, not generic filler.
21
+ - Keep `confidence` between 0 and 1.
22
+
23
+ Decision payload:
24
+ [[payload_json]]