nexo-brain 7.9.14 → 7.9.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.14",
3
+ "version": "7.9.15",
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
@@ -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,14 @@ 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
+ _migrate_add_column(conn, "cortex_evaluations", "heuristic_choice", "TEXT DEFAULT ''")
915
+ _migrate_add_column(conn, "cortex_evaluations", "heuristic_reasoning", "TEXT DEFAULT ''")
916
+ _migrate_add_column(conn, "cortex_evaluations", "critique_payload", "TEXT DEFAULT '{}'")
917
+ _migrate_add_column(conn, "cortex_evaluations", "decision_mode", "TEXT DEFAULT 'heuristic'")
918
+
919
+
912
920
  def _m39_hook_runs(conn):
913
921
  """Persist hook lifecycle observability — closes Fase 3 item 7.
914
922
 
@@ -1479,6 +1487,7 @@ MIGRATIONS = [
1479
1487
  (52, "lifecycle_canonical_plan", _m52_lifecycle_canonical_plan),
1480
1488
  (53, "session_conversation_identity", _m53_session_conversation_identity),
1481
1489
  (54, "continuity_snapshots", _m54_continuity_snapshots),
1490
+ (55, "cortex_critique_trace", _m55_cortex_critique_trace),
1482
1491
  ]
1483
1492
 
1484
1493
 
@@ -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",
@@ -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]]