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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/classifier_local.py +176 -0
- package/src/cli.py +17 -4
- package/src/cognitive/_core.py +36 -0
- package/src/cognitive/_trust.py +95 -10
- package/src/db/_core.py +5 -0
- package/src/db/_schema.py +38 -0
- package/src/enforcement_classifier.py +31 -6
- package/src/enforcement_engine.py +159 -0
- package/src/fase_f_loops.py +194 -0
- package/src/hook_guardrails.py +14 -0
- package/src/hooks/auto_capture.py +67 -0
- package/src/nexo_migrate.py +158 -0
- package/src/plugin_loader.py +86 -0
- package/src/plugins/cognitive_memory.py +3 -0
- package/src/presets/entities_universal.json +41 -0
- package/src/presets/guardian_default.json +2 -1
- package/src/r34_identity_coherence.py +132 -0
- package/src/r_catalog.py +72 -0
- package/src/scripts/phase_guardian_analysis.py +114 -0
- package/src/server.py +31 -1
- package/src/system_catalog.py +54 -0
- package/src/t4_llm_gate.py +174 -0
- package/src/tools_email_guard.py +88 -0
- package/src/tools_guardian.py +183 -0
- package/templates/CLAUDE.md.template +9 -0
- package/templates/CODEX.AGENTS.md.template +7 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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]:
|
package/src/cognitive/_core.py
CHANGED
|
@@ -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
|
package/src/cognitive/_trust.py
CHANGED
|
@@ -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
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
|
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
|
|