nexo-brain 6.1.0 → 6.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.1.0",
3
+ "version": "6.3.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
@@ -18,7 +18,9 @@
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 `6.1.0` is the current packaged-runtime line: the Protocol Enforcer Fase 2 ships a Capa 2 runtime guardian with 25 rules (R13–R25 + R23b–R23m) that keep Claude Code / Codex / Desktop aligned to NEXO protocol. Rule coverage: pre-Edit guard (R13), post-correction learning window (R14), declared-done without close (R16), Nora/María read-only destructive block (R25), plus 21 more across stream wrapper layers (Fase C + D + D2). Also lands migration v43 `session_claude_aliases` a 1-to-N map from NEXO sid to every Claude session UUID, fixing the NEXO Desktop multi-conversation block where every second conversation's PreToolUse hook failed with "unknown target". External-LLM audit + Opus 4.7 self-audit cycle applied (log redaction with modern token formats, R23f heredoc multiline, R23h native PATH resolution, R14 awaited, hermetic map lookup, cross-engine parity harness strict). Suite: 291 pass + 2 skip documented.
21
+ Version `6.3.0` is the current packaged-runtime line Plan Consolidado wave 2, coordinated with NEXO Desktop v0.18.0. Closes the remaining Guardian roadmap items that do not require an invasive structure migration: extended `cognitive_sentiment` shape (is_correction/valence/intent), extended `entities` schema, 21 labelled rule fixtures with R13 spike gates, Fase F telemetry loops + Deep Sleep phase, pinned local zero-shot classifier skeleton (mDeBERTa), hook respects `NEXO_MIGRATING=1`, `origin` column on `personal_scripts`, and the T4 LLM gate wrapping R15/R23e/R23f/R23h (byte-parity Py JS). Two pre-release auditors flagged a CRITICAL in the first JS wire (method-name + async mismatch) and a HIGH (classifier bool conflated "no" with "unparseable"); both corrected with regression tests before merge.
22
+
23
+ Previously in `6.1.1`: small fix to `nexo --help` so the `Latest: vX` line reliably appears when NEXO Desktop invokes the CLI via subprocess — unblocks the Desktop Brain auto-update banner that previously couldn't parse the version delta. No behaviour change for interactive terminal users; the 6-hour registry cache still rate-limits network calls. Bundles all v6.1.0 Protocol Enforcer Fase 2 + multi-claude-sid hotfix content.
22
24
 
23
25
  Previously in `6.0.2`: adds the reserved caller prefix `personal/*` so scripts living in `~/.nexo/scripts/` can invoke the automation backend with their own caller id without editing `src/resonance_map.py`. New kwarg `tier` (`"maximo"` / `"alto"` / `"medio"` / `"bajo"`) on `run_automation_prompt`, `run_automation_interactive`, `nexo_helper.run_automation_text`, `nexo_helper.run_automation_json`, and `nexo-agent-run.py --tier`. Precedence for `personal/*` callers: explicit `tier=` → explicit `reasoning_effort=` → `calibration.preferences.default_resonance` → `DEFAULT_RESONANCE` (`alto`). Registered callers keep their behaviour unchanged. New guide: [`docs/personal-scripts-guide.md`](docs/personal-scripts-guide.md).
24
26
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.1.0",
3
+ "version": "6.3.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
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.",
5
+ "description": "NEXO Brain \u2014 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",
7
7
  "bin": {
8
8
  "nexo-brain": "./bin/nexo-brain.js",
@@ -0,0 +1,176 @@
1
+ """Plan Consolidado 0.21 — Local zero-shot multilingual classifier.
2
+
3
+ Skeleton + pinned HuggingFace coordinates. The heavy load
4
+ (`transformers`, ~500 MB model download) is lazy so the rest of the
5
+ runtime does not pay the cost on every import.
6
+
7
+ Contract:
8
+
9
+ clf = LocalZeroShotClassifier()
10
+ result = clf.classify(
11
+ "lo hemos dejado, ya estaría",
12
+ labels=("done_claim", "status_update", "question", "noise"),
13
+ )
14
+ result == {"label": "done_claim", "confidence": 0.87, "scores": {...}}
15
+
16
+ When transformers is not installed or the download fails (offline),
17
+ `classify` returns `None` and `classify_fail_closed` returns a
18
+ conservative fallback label so rules degrade gracefully (item 0.20).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import threading
25
+ from dataclasses import dataclass
26
+ from typing import Iterable
27
+
28
+ _logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Keep in lockstep with docs/classifier-model-notes.md.
32
+ # Plan 0.21 wave-2 update: the original pin
33
+ # (MoritzLaurer/mDeBERTa-v3-base-mnli-xnli @ a1a5a76) refused to load
34
+ # under transformers 5.x with a missing `model_type` error. Switched
35
+ # to the multilingual-2mil7 sibling which is the same DeBERTa-v2
36
+ # architecture, multilingual, and loads cleanly. Revision pinned to
37
+ # the last HF upstream commit verified in smoke.
38
+ MODEL_ID = "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
39
+ MODEL_REVISION = "b5113eb38ab63efdd7f280f8c144ea8b13f978ce"
40
+ DEFAULT_CONFIDENCE_FLOOR = 0.6
41
+
42
+
43
+ @dataclass
44
+ class ClassificationResult:
45
+ label: str
46
+ confidence: float
47
+ scores: dict[str, float]
48
+ latency_ms: float
49
+
50
+
51
+ class LocalZeroShotClassifier:
52
+ """Lazy wrapper around transformers' zero-shot-classification pipeline.
53
+
54
+ Thread-safe lazy load; failures degrade to `classify(...) = None` so
55
+ the Guardian can decide whether to invoke the LLM fallback
56
+ (`call_model_raw`) or a conservative regex path.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ model_id: str = MODEL_ID,
63
+ revision: str = MODEL_REVISION,
64
+ confidence_floor: float = DEFAULT_CONFIDENCE_FLOOR,
65
+ ) -> None:
66
+ self.model_id = model_id
67
+ self.revision = revision
68
+ self.confidence_floor = confidence_floor
69
+ self._pipe = None
70
+ self._load_failed = False
71
+ self._lock = threading.Lock()
72
+
73
+ # ------------------------------------------------------------------
74
+ # Lazy load
75
+ # ------------------------------------------------------------------
76
+ def _ensure_loaded(self) -> bool:
77
+ if self._pipe is not None:
78
+ return True
79
+ if self._load_failed:
80
+ return False
81
+ with self._lock:
82
+ if self._pipe is not None:
83
+ return True
84
+ if self._load_failed:
85
+ return False
86
+ try:
87
+ from transformers import pipeline # type: ignore
88
+ except Exception as exc: # pragma: no cover — no HF on CI
89
+ _logger.warning(
90
+ "classifier_local disabled: transformers unavailable (%s)",
91
+ exc,
92
+ )
93
+ self._load_failed = True
94
+ return False
95
+ try:
96
+ self._pipe = pipeline(
97
+ "zero-shot-classification",
98
+ model=self.model_id,
99
+ revision=self.revision,
100
+ device=-1, # CPU-only
101
+ )
102
+ return True
103
+ except Exception as exc: # pragma: no cover — network / disk
104
+ _logger.warning(
105
+ "classifier_local pipeline failed to initialise: %s", exc
106
+ )
107
+ self._load_failed = True
108
+ return False
109
+
110
+ # ------------------------------------------------------------------
111
+ # Public API
112
+ # ------------------------------------------------------------------
113
+ def is_available(self) -> bool:
114
+ return self._ensure_loaded()
115
+
116
+ def classify(
117
+ self,
118
+ text: str,
119
+ labels: Iterable[str],
120
+ *,
121
+ multi_label: bool = False,
122
+ ) -> ClassificationResult | None:
123
+ """Return best label + confidence or None if the local pipeline
124
+ is unavailable."""
125
+ if not text or not labels:
126
+ return None
127
+ if not self._ensure_loaded():
128
+ return None
129
+ import time
130
+ t0 = time.time()
131
+ try:
132
+ raw = self._pipe( # type: ignore[operator]
133
+ text,
134
+ candidate_labels=list(labels),
135
+ multi_label=multi_label,
136
+ )
137
+ except Exception as exc: # pragma: no cover
138
+ _logger.warning("classifier_local inference failed: %s", exc)
139
+ return None
140
+ latency_ms = (time.time() - t0) * 1000.0
141
+ scores = dict(zip(raw["labels"], raw["scores"]))
142
+ top_label = raw["labels"][0]
143
+ return ClassificationResult(
144
+ label=top_label,
145
+ confidence=float(raw["scores"][0]),
146
+ scores=scores,
147
+ latency_ms=latency_ms,
148
+ )
149
+
150
+ def classify_fail_closed(
151
+ self,
152
+ text: str,
153
+ labels: Iterable[str],
154
+ fallback_label: str,
155
+ ) -> ClassificationResult:
156
+ """Never returns None — falls back to `fallback_label` with
157
+ confidence 0 so the Guardian can still decide without crashing.
158
+ """
159
+ got = self.classify(text, labels)
160
+ if got is not None and got.confidence >= self.confidence_floor:
161
+ return got
162
+ return ClassificationResult(
163
+ label=fallback_label,
164
+ confidence=0.0,
165
+ scores={label: 0.0 for label in labels},
166
+ latency_ms=0.0,
167
+ )
168
+
169
+
170
+ __all__ = [
171
+ "LocalZeroShotClassifier",
172
+ "ClassificationResult",
173
+ "MODEL_ID",
174
+ "MODEL_REVISION",
175
+ "DEFAULT_CONFIDENCE_FLOOR",
176
+ ]
package/src/cli.py CHANGED
@@ -118,10 +118,23 @@ def _fetch_latest_version(timeout_seconds: int = 2) -> str | None:
118
118
 
119
119
 
120
120
  def _should_refresh_latest_version() -> bool:
121
- try:
122
- return sys.stdout.isatty() or sys.stderr.isatty()
123
- except Exception:
124
- return False
121
+ """Decide whether to hit the npm registry to refresh `latest` version.
122
+
123
+ Prior behaviour gated this on `isatty()` so `nexo --help` never made
124
+ a network call outside an interactive terminal. That also meant NEXO
125
+ Desktop — which spawns `nexo` via subprocess with piped stdio — could
126
+ never populate the version cache, so the Desktop update banner for
127
+ Brain never saw a newer `Latest: vX` line in the help output and no
128
+ Brain update was ever offered automatically (v6.1.1 fix).
129
+
130
+ The 6-hour `max_age_seconds` at `_load_latest_version_cache()` is the
131
+ real rate-limit. This function now returns True unconditionally so
132
+ missing/stale cache entries are always refreshed, regardless of tty
133
+ context. Fail-closed: `_fetch_latest_version` still catches every
134
+ subprocess error and returns None, so the help line falls back to
135
+ installed-only when npm is unreachable.
136
+ """
137
+ return True
125
138
 
126
139
 
127
140
  def _version_sort_key(raw: str) -> tuple[tuple[int, ...], int, str]:
@@ -67,6 +67,42 @@ URGENCY_SIGNALS = {
67
67
  "rápido", "ya", "ahora", "urgente", "asap", "inmediatamente", "corre",
68
68
  }
69
69
 
70
+ # Correction signals — text patterns that indicate the user is correcting NEXO.
71
+ # Stronger than generic negative: implies "you were wrong, here's the truth".
72
+ CORRECTION_SIGNALS = {
73
+ "no es", "no era", "te equivocas", "estás equivocad", "eso no",
74
+ "está mal", "esta mal", "mal hecho", "eso es falso",
75
+ "incorrecto", "ya te dije",
76
+ # Auditor H2 removed "otra vez" — benign phrases like
77
+ # "envíame la lista otra vez" were producing false corrections.
78
+ "es al revés", "es al reves",
79
+ "wrong", "that's wrong", "you're wrong", "incorrect",
80
+ "not quite", "actually,", "fix it",
81
+ }
82
+
83
+ # Acknowledgement signals — user explicitly confirms something NEXO proposed.
84
+ ACKNOWLEDGEMENT_SIGNALS = {
85
+ "gracias", "perfecto", "genial", "exactly", "correcto",
86
+ "así es", "asi es", "bien hecho", "buen trabajo",
87
+ }
88
+
89
+ # Instruction signals — user asks NEXO to do something.
90
+ INSTRUCTION_SIGNALS = {
91
+ "haz ", "hazlo", "crea ", "ejecuta ", "implementa ", "arregla ",
92
+ "envía ", "envia ", "mueve ", "dime ", "revisa ", "borra ",
93
+ "actualiza ", "publica ", "lanza ",
94
+ "run ", "execute ", "implement ", "send ", "review ",
95
+ "update ", "publish ", "ship ",
96
+ }
97
+
98
+ # Question signals — interrogatives.
99
+ QUESTION_SIGNALS = {
100
+ "?", "¿", "qué ", "cómo ", "cuándo ", "dónde ", "por qué", "cual ",
101
+ "cuál ", "puedes ", "podrías ",
102
+ "what ", "how ", "when ", "where ", "why ", "which ", "can you",
103
+ "could you",
104
+ }
105
+
70
106
  # Trust score events — default deltas (overridable via trust_event_config table)
71
107
  _DEFAULT_TRUST_EVENTS = {
72
108
  # Positive
@@ -3,7 +3,15 @@ import re
3
3
  import numpy as np
4
4
  from datetime import datetime, timedelta, timezone
5
5
  from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array
6
- from cognitive._core import POSITIVE_SIGNALS, NEGATIVE_SIGNALS, URGENCY_SIGNALS
6
+ from cognitive._core import (
7
+ POSITIVE_SIGNALS,
8
+ NEGATIVE_SIGNALS,
9
+ URGENCY_SIGNALS,
10
+ CORRECTION_SIGNALS,
11
+ ACKNOWLEDGEMENT_SIGNALS,
12
+ INSTRUCTION_SIGNALS,
13
+ QUESTION_SIGNALS,
14
+ )
7
15
 
8
16
 
9
17
  # Trust score events — default deltas (overridable via trust_event_config table)
@@ -248,31 +256,57 @@ def check_correction_fatigue() -> list[dict]:
248
256
 
249
257
  return fatigued
250
258
 
259
+ SENTIMENT_INTENTS = (
260
+ "correction",
261
+ "acknowledgement",
262
+ "question",
263
+ "instruction",
264
+ "urgency",
265
+ "complaint",
266
+ "praise",
267
+ "neutral",
268
+ )
269
+
270
+
251
271
  def detect_sentiment(text: str) -> dict:
252
272
  """Analyze user's text for sentiment signals.
253
273
 
254
- Returns detected sentiment, intensity, and action guidance for NEXO.
274
+ Returns detected sentiment, intensity, action guidance, and the structured
275
+ shape required by Plan Consolidado 0.2:
276
+ - is_correction: bool
277
+ - valence: float in [-1.0, 1.0]
278
+ - intent: enum (SENTIMENT_INTENTS)
279
+
255
280
  Not a model — keyword + heuristic based. Fast and deterministic.
256
281
  """
257
282
  if not text:
258
- return {"sentiment": "neutral", "intensity": 0.5, "signals": [], "guidance": ""}
283
+ return {
284
+ "sentiment": "neutral",
285
+ "intensity": 0.5,
286
+ "signals": [],
287
+ "guidance": "",
288
+ "is_correction": False,
289
+ "valence": 0.0,
290
+ "intent": "neutral",
291
+ }
259
292
 
260
293
  text_lower = text.lower()
261
- words = set(text_lower.split())
262
294
 
263
295
  positive_hits = [s for s in POSITIVE_SIGNALS if s in text_lower]
264
296
  negative_hits = [s for s in NEGATIVE_SIGNALS if s in text_lower]
265
297
  urgency_hits = [s for s in URGENCY_SIGNALS if s in text_lower]
298
+ correction_hits = [s for s in CORRECTION_SIGNALS if s in text_lower]
299
+ ack_hits = [s for s in ACKNOWLEDGEMENT_SIGNALS if s in text_lower]
300
+ instruction_hits = [s for s in INSTRUCTION_SIGNALS if s in text_lower]
301
+ question_hits = [s for s in QUESTION_SIGNALS if s in text_lower]
266
302
 
267
303
  # Heuristics
268
304
  is_short = len(text) < 30
269
- has_caps = any(c.isupper() for c in text[1:]) if len(text) > 1 else False # ignore first char
270
- has_exclamation = "!" in text
271
305
  all_caps_words = sum(1 for w in text.split() if w.isupper() and len(w) > 1)
272
306
 
273
307
  # Score
274
- pos_score = len(positive_hits)
275
- neg_score = len(negative_hits)
308
+ pos_score = len(positive_hits) + len(ack_hits)
309
+ neg_score = len(negative_hits) + len(correction_hits)
276
310
 
277
311
  # Caps/short boost negative
278
312
  if all_caps_words >= 2:
@@ -283,7 +317,7 @@ def detect_sentiment(text: str) -> dict:
283
317
  if urgency_hits:
284
318
  neg_score += 1 # Urgency often means something is wrong
285
319
 
286
- # Determine sentiment
320
+ # Determine sentiment label
287
321
  if neg_score > pos_score and neg_score >= 1:
288
322
  sentiment = "negative"
289
323
  intensity = min(1.0, 0.3 + neg_score * 0.15)
@@ -304,11 +338,62 @@ def detect_sentiment(text: str) -> dict:
304
338
  intensity = 0.5
305
339
  guidance = ""
306
340
 
341
+ # Valence: normalized -1..1 from raw pos/neg counts (ignores caps boost).
342
+ raw_pos = len(positive_hits) + len(ack_hits)
343
+ raw_neg = len(negative_hits) + len(correction_hits)
344
+ denom = max(raw_pos + raw_neg, 1)
345
+ valence = round((raw_pos - raw_neg) / denom, 3)
346
+ if urgency_hits and valence >= 0:
347
+ valence = round(min(valence - 0.2, 1.0), 3)
348
+
349
+ # is_correction: prioritize explicit correction signals. The
350
+ # fallback path (no explicit CORRECTION_SIGNALS hit) requires a
351
+ # stronger combination to avoid false-positives from general venting
352
+ # directed at third-party systems (e.g. "FAILED", "no funciona"):
353
+ # explicit 2+ all-caps words OR a direct second-person reference
354
+ # (tú/te/you) that anchors the correction at NEXO.
355
+ second_person = any(
356
+ tok in (" " + text_lower + " ")
357
+ for tok in (" tú ", " te ", " you ", " eso ", " eso que ")
358
+ )
359
+ is_correction = bool(correction_hits) or (
360
+ sentiment == "negative"
361
+ and is_short
362
+ and not question_hits
363
+ and (
364
+ all_caps_words >= 2
365
+ or (raw_neg >= 2 and second_person)
366
+ )
367
+ )
368
+
369
+ # Intent: prioritized enum — correction > question > instruction >
370
+ # urgency > acknowledgement/praise > complaint > neutral.
371
+ if is_correction:
372
+ intent = "correction"
373
+ elif question_hits:
374
+ intent = "question"
375
+ elif instruction_hits:
376
+ intent = "instruction"
377
+ elif urgency_hits:
378
+ intent = "urgency"
379
+ elif ack_hits and sentiment != "negative":
380
+ intent = "acknowledgement"
381
+ elif positive_hits and sentiment == "positive":
382
+ intent = "praise"
383
+ elif sentiment == "negative":
384
+ intent = "complaint"
385
+ else:
386
+ intent = "neutral"
387
+
307
388
  return {
308
389
  "sentiment": sentiment,
309
390
  "intensity": round(intensity, 2),
310
- "signals": positive_hits + negative_hits + urgency_hits,
391
+ "signals": positive_hits + negative_hits + urgency_hits
392
+ + correction_hits + ack_hits + instruction_hits,
311
393
  "guidance": guidance,
394
+ "is_correction": is_correction,
395
+ "valence": valence,
396
+ "intent": intent,
312
397
  }
313
398
 
314
399
 
package/src/db/_core.py CHANGED
@@ -259,6 +259,11 @@ def init_db():
259
259
  type TEXT NOT NULL DEFAULT 'general',
260
260
  value TEXT NOT NULL,
261
261
  notes TEXT DEFAULT '',
262
+ aliases TEXT DEFAULT '[]',
263
+ metadata TEXT DEFAULT '{}',
264
+ source TEXT NOT NULL DEFAULT 'manual',
265
+ confidence REAL NOT NULL DEFAULT 1.0,
266
+ access_mode TEXT DEFAULT 'unknown',
262
267
  created_at REAL NOT NULL,
263
268
  updated_at REAL NOT NULL
264
269
  );
package/src/db/_schema.py CHANGED
@@ -406,6 +406,7 @@ def _m20_personal_scripts_registry(conn):
406
406
  last_run_at TEXT DEFAULT NULL,
407
407
  last_exit_code INTEGER DEFAULT NULL,
408
408
  last_synced_at TEXT DEFAULT (datetime('now')),
409
+ origin TEXT NOT NULL DEFAULT 'user',
409
410
  created_at TEXT DEFAULT (datetime('now')),
410
411
  updated_at TEXT DEFAULT (datetime('now'))
411
412
  )
@@ -1082,6 +1083,41 @@ def _m43_session_claude_aliases(conn):
1082
1083
  )
1083
1084
 
1084
1085
 
1086
+ def _m45_personal_scripts_origin(conn):
1087
+ """Plan Consolidado F0.1 — mark whether a personal_scripts row is
1088
+ installed by NEXO Core (origin='core'), contributed by the operator
1089
+ (origin='user'), or a dev-only core-dev script (origin='core-dev').
1090
+
1091
+ Used by `nexo update` to know which rows it can replace without
1092
+ overwriting operator-authored automations, and by the Desktop
1093
+ Automations panel (F0.2) to segment the list.
1094
+
1095
+ Idempotent.
1096
+ """
1097
+ _migrate_add_column(conn, "personal_scripts", "origin", "TEXT NOT NULL DEFAULT 'user'")
1098
+ _migrate_add_index(conn, "idx_personal_scripts_origin", "personal_scripts", "origin")
1099
+
1100
+
1101
+ def _m44_entities_extended_schema(conn):
1102
+ """Plan Consolidado 0.3 — extend entities with aliases/metadata/source/confidence/access_mode.
1103
+
1104
+ - aliases: TEXT DEFAULT '[]' (JSON array of alternative names)
1105
+ - metadata: TEXT DEFAULT '{}' (JSON object of arbitrary key/value)
1106
+ - source: TEXT DEFAULT 'manual'
1107
+ (enum: preset | manual | quarantine_approved | auto_detected)
1108
+ - confidence: REAL DEFAULT 1.0 (0..1 — preset=1.0, quarantine≈0.6)
1109
+ - access_mode: TEXT DEFAULT 'unknown'
1110
+ (enum: read_only | read_write | write_only | unknown)
1111
+
1112
+ Idempotent.
1113
+ """
1114
+ _migrate_add_column(conn, "entities", "aliases", "TEXT DEFAULT '[]'")
1115
+ _migrate_add_column(conn, "entities", "metadata", "TEXT DEFAULT '{}'")
1116
+ _migrate_add_column(conn, "entities", "source", "TEXT NOT NULL DEFAULT 'manual'")
1117
+ _migrate_add_column(conn, "entities", "confidence", "REAL NOT NULL DEFAULT 1.0")
1118
+ _migrate_add_column(conn, "entities", "access_mode", "TEXT DEFAULT 'unknown'")
1119
+
1120
+
1085
1121
  MIGRATIONS = [
1086
1122
  (1, "learnings_columns", _m1_learnings_columns),
1087
1123
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1126,6 +1162,8 @@ MIGRATIONS = [
1126
1162
  (41, "automation_sessions_columns", _m41_automation_sessions_columns),
1127
1163
  (42, "v6_0_1_hotfix", _m42_v6_0_1_hotfix),
1128
1164
  (43, "session_claude_aliases", _m43_session_claude_aliases),
1165
+ (44, "entities_extended_schema", _m44_entities_extended_schema),
1166
+ (45, "personal_scripts_origin", _m45_personal_scripts_origin),
1129
1167
  ]
1130
1168
 
1131
1169
 
@@ -121,7 +121,8 @@ def classify(
121
121
  call_raw: Callable[..., str] = call_model_raw,
122
122
  cache: _TTLCache = _cache,
123
123
  tier: str = "muy_bajo",
124
- ) -> bool:
124
+ tristate: bool = False,
125
+ ):
125
126
  """Run a triple-reinforced yes/no classification.
126
127
 
127
128
  Args:
@@ -130,11 +131,23 @@ def classify(
130
131
  call_raw: Injection point for tests — defaults to call_model_raw.
131
132
  cache: TTL cache instance. Tests can pass a fresh cache.
132
133
  tier: Resonance tier. Default "muy_bajo" (Haiku / gpt-5.4-mini).
134
+ tristate: When True, return "yes" / "no" / "unknown" as strings.
135
+ "unknown" represents the conservative-parse-fallback path
136
+ which existing bool callers cannot distinguish from a real
137
+ "no". Plan Consolidado wave-2 auditor H1 required this
138
+ differentiation for destructive rules (R23e, R23f, R23h)
139
+ wired through the T4 gate: silently suppressing a rule on
140
+ an unparseable classifier answer is fail-open and unsafe.
141
+ Default False keeps legacy callers compatible.
133
142
 
134
143
  Returns:
135
- True iff the classifier confidently answers "yes". False otherwise
136
- (including when the second retry fails — conservative fallback per
137
- plan doc 1 "triple refuerzo").
144
+ With ``tristate=False`` (default): ``True`` iff the classifier
145
+ confidently answers "yes", ``False`` otherwise (including the
146
+ conservative fallback when both retries fail to parse).
147
+
148
+ With ``tristate=True``: one of the strings ``"yes"``, ``"no"``,
149
+ or ``"unknown"``. ``"unknown"`` is returned when both retries
150
+ produce an unparseable response.
138
151
 
139
152
  Raises:
140
153
  ClassifierUnavailableError: Propagated from call_model_raw when the
@@ -145,6 +158,8 @@ def classify(
145
158
  cached = cache.get(key)
146
159
  if cached is not None:
147
160
  _logger.debug("CACHE_HIT key=%s → %s", key[:12], cached)
161
+ if tristate:
162
+ return "yes" if cached else "no"
148
163
  return cached
149
164
 
150
165
  user_text = question if not context else f"{question}\n\nContext:\n{context}"
@@ -161,6 +176,8 @@ def classify(
161
176
  if parsed is not None:
162
177
  cache.put(key, parsed)
163
178
  _logger.debug("FIRST_OK raw=%r → %s", first, parsed)
179
+ if tristate:
180
+ return "yes" if parsed else "no"
164
181
  return parsed
165
182
 
166
183
  # Retry with stricter reformulation — one time, then give up conservative.
@@ -176,13 +193,21 @@ def classify(
176
193
  if parsed is not None:
177
194
  cache.put(key, parsed)
178
195
  _logger.debug("RETRY_OK raw=%r → %s", second, parsed)
196
+ if tristate:
197
+ return "yes" if parsed else "no"
179
198
  return parsed
180
199
 
181
- # Both attempts unparseable. Conservative default: NO.
200
+ # Both attempts unparseable. Legacy callers get the conservative False;
201
+ # tristate callers get "unknown" so a T4 destructive-rule gate can
202
+ # fall through to regex instead of silently suppressing the rule
203
+ # on an unparseable classifier answer.
182
204
  _logger.warning(
183
- "PARSER_FAIL (fallback no) first=%r second=%r q=%r",
205
+ "PARSER_FAIL (fallback) first=%r second=%r q=%r",
184
206
  first, second, question[:120],
185
207
  )
208
+ if tristate:
209
+ # Do NOT cache "unknown" — retrying on the next call is desirable.
210
+ return "unknown"
186
211
  cache.put(key, False)
187
212
  return False
188
213