nexo-brain 7.9.1 → 7.9.3

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.1",
3
+ "version": "7.9.3",
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 `7.9.1` is the current packaged-runtime line. Patch release that starts the semantic-router site migration promised after v7.9.0: six safe textual-conversational callers now route through `semantic_router.route(...)` instead of importing `enforcement_classifier.classify` directly (`session_end_intent`, `r14_correction_learning`, `r16_declared_done`, `r17_promise_debt`, `autonomy_mandate`, `guard_verbal_ack`). The patch also fixes the semantic stack's local layers to classify the live `context` text rather than letting static prompt templates dominate zero-shot decisions, and migrates the six callers to semantic labels (`session_end`/`continue_session`, `negative_feedback`/`ordinary_request`, etc.) instead of generic `yes`/`no`. Existing fail-closed behaviour and test injection seams are preserved. Targeted verification: 105 tests passing across router, reasoner, migrated call sites, and their enforcement integrations. Remaining textual/code-aware callers stay tracked under `NF-SEMANTIC-ROUTER-SITE-MIGRATION` for later focused patches. No Desktop bump.
21
+ Version `7.9.3` is the current packaged-runtime line. Patch release that hardens Brain's canonical lifecycle plan for Desktop close/archive/delete/app-exit diary guarantees: `canonical_actions` now publish the v2 canonical shape (`type` plus `payload.prompt`) while keeping one-release compatibility mirrors (`kind` plus top-level `prompt`) for older Desktop clients. This lets Desktop execute resume diary prompt stop with one exact owner per lifecycle event and preserve Brain-side dedupe by event id. Targeted verification: `pytest tests/test_lifecycle_events.py` (25 passing) plus release-readiness after artifact sync.
22
+
23
+ Previously in `7.9.2`: patch release that completes the Brain semantic-router site migration: the remaining decision callers now route through `semantic_router.route(...)` with named `decision_kind` policies (`r20_constant_change`, `r34_identity_coherence`, `t4_r15`, `t4_r23e`, `t4_r23f`, `t4_r23h`, `followup_operator_attention`, `drive_signal_type`, `drive_area`, `reply_event_type`, `query_intent`, and `sentiment_intent`). Brain now owns model choice, thresholds, and fallback behaviour centrally instead of each caller carrying its own classifier policy. The patch also fixes packaged headless Guardian map loading: `enforcement_engine` and `agent_runner` now check the installed core directory (`~/.nexo/core/tool-enforcement-map.json`) so followup-runner, morning-agent, digest, and email-monitor load the map instead of falling back to unguarded subprocess execution. Targeted verification: 100 semantic/router/enforcer tests, 125 Drive/cognitive/productization tests, and release-readiness passing.
22
24
 
23
25
  Previously in `7.9.0`: minor release that ships the foundation of the semantic stack (router + reasoner + CLI) under the ONEPASS LLM Coverage plan, plus two product-bug fixes observed in the wild on 2026-04-23. New `src/semantic_router.py` exposes 18 named `decision_kinds` (13 textual + 5 code-aware) with a per-kind policy table and the layer chain `fast_local → semantic_reasoner → remote_fallback`. New `src/semantic_reasoner.py` adds Mode A (`multipass_local`: reuses the mDeBERTa pin with three prompt-perturbed passes + majority vote + 0.75 floor) and Mode B (`cached_llm`: wrapper over `call_model_raw` with a pid+uuid atomic-write 24h-TTL disk cache at `~/.nexo/runtime/operations/semantic-reasoner-cache.json`, SHA-256 keyed by `decision_kind` + normalized input, LRU-bounded at 2000 entries, corrupt entries dropped on read). New `scripts/semantic-classify.py` JSON-in JSON-out CLI lets external MCP clients (including the closed-source NEXO Desktop companion) query Brain as the single semantic authority. New `NEXO_SEMANTIC_REASONER` kill switch (`0`/`off`/`false`/`no`/`disable`/`disabled`) honours the plan mandate for a runtime opt-out separate from `NEXO_LOCAL_CLASSIFIER`. Bug fixes: `bin/nexo-brain.js` upgrade flow now copies `templates/` root the same way fresh install and same-version refresh already did (Maria iMac 7.1.10→7.8.1 upgrade had lost 27 core-prompts templates and broken post-update import verification); and `tool-enforcement-map.json` `nexo_startup.enforcement.inject_prompt` now instructs the model to preload the 13 `mcp__nexo__*` protocol tools via `ToolSearch` before calling `nexo_startup` when the host MCP client defers tool schemas (Claude Code with many MCPs installed). Audit-driven hardening: router/reasoner defensively use `getattr` over the `call_model_raw` module and add a trailing `except Exception` so provider errors degrade with `remote_error` instead of propagating; cache writes use pid+uuid tmp + `fsync` + `os.replace` to survive concurrent writers; `NEXO_SEMANTIC_REASONER_TTL` parse tolerates malformed values. Tests: +50 (22 router, 20 reasoner, 8 CLI). Per-site migration of existing callers (`session_end_intent`, `r14`, `r16`, `r17`, `r20`, `r34`, T4 gates, `tools_drive`, `nexo-followup-runner`) is explicitly deferred to follow-up patch releases and tracked as `NF-SEMANTIC-ROUTER-SITE-MIGRATION`; nothing in this release changes the behaviour of the existing callers. Companion coordinated release: NEXO Desktop v0.28.0.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.1",
3
+ "version": "7.9.3",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
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",
@@ -792,6 +792,7 @@ def _build_enforcement_system_prompt() -> str:
792
792
  map_path = NEXO_HOME / "tool-enforcement-map.json"
793
793
  if not map_path.exists():
794
794
  for candidate in [
795
+ Path(__file__).parent / "tool-enforcement-map.json",
795
796
  Path(__file__).parent.parent / "tool-enforcement-map.json",
796
797
  ]:
797
798
  if candidate.exists():
@@ -27,7 +27,6 @@ _QUERY_INTENT_LOCAL_CONFIDENCE_THRESHOLD = float(
27
27
  _QUERY_INTENT_CACHE_TTL_SECONDS = int(
28
28
  os.environ.get("NEXO_QUERY_INTENT_LOCAL_CACHE_TTL", "21600")
29
29
  )
30
- _LOCAL_QUERY_INTENT_CLASSIFIER = None
31
30
  _QUERY_INTENT_CACHE: dict[str, dict] = {}
32
31
  _QUERY_INTENT_LABELS = (
33
32
  ("A how-to guide, procedure, or step-by-step instruction request", "howto"),
@@ -75,27 +74,27 @@ def _local_classify_query_intent(query: str) -> dict:
75
74
  if cached and cached.get("expires_at", 0) > time.time():
76
75
  return {k: v for k, v in cached.items() if k != "expires_at"}
77
76
 
78
- global _LOCAL_QUERY_INTENT_CLASSIFIER
79
77
  try:
80
- if _LOCAL_QUERY_INTENT_CLASSIFIER is None:
81
- from classifier_local import LocalZeroShotClassifier
82
-
83
- _LOCAL_QUERY_INTENT_CLASSIFIER = LocalZeroShotClassifier(
84
- confidence_floor=_QUERY_INTENT_LOCAL_CONFIDENCE_THRESHOLD,
85
- )
86
- if not _LOCAL_QUERY_INTENT_CLASSIFIER.is_available():
87
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
78
+ from semantic_router import route as semantic_route
79
+ except Exception as exc:
80
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
81
+ try:
88
82
  label_texts = [label for label, _intent in _QUERY_INTENT_LABELS]
89
83
  intent_by_label = {label: intent for label, intent in _QUERY_INTENT_LABELS}
90
- result = _LOCAL_QUERY_INTENT_CLASSIFIER.classify(query, label_texts)
91
- if result is None:
92
- return {"available": False, "label": None, "reason": "classifier_failed"}
84
+ result = semantic_route(
85
+ decision_kind="query_intent",
86
+ question="Classify the user's search query intent.",
87
+ context=query,
88
+ labels=tuple(label_texts),
89
+ )
90
+ if not result.ok:
91
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
93
92
  intent = intent_by_label.get(result.label)
94
93
  payload = {
95
94
  "available": intent is not None,
96
95
  "label": intent,
97
96
  "confidence": float(result.confidence or 0.0),
98
- "reason": "local_zero_shot",
97
+ "reason": result.route_used,
99
98
  }
100
99
  _QUERY_INTENT_CACHE[key] = {
101
100
  **payload,
@@ -103,7 +102,7 @@ def _local_classify_query_intent(query: str) -> dict:
103
102
  }
104
103
  return payload
105
104
  except Exception as exc:
106
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
105
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
107
106
 
108
107
  def bm25_search(query_text: str, stores: str = "both", top_k: int = 20,
109
108
  source_type_filter: str = "") -> list[dict]:
@@ -274,7 +274,6 @@ _LOCAL_SENTIMENT_CONFIDENCE_THRESHOLD = float(
274
274
  _LOCAL_SENTIMENT_CACHE_TTL_SECONDS = int(
275
275
  os.environ.get("NEXO_SENTIMENT_LOCAL_CACHE_TTL", "21600")
276
276
  )
277
- _LOCAL_SENTIMENT_CLASSIFIER = None
278
277
  _LOCAL_SENTIMENT_CACHE: dict[str, dict] = {}
279
278
  _LOCAL_SENTIMENT_LABELS = (
280
279
  ("The user is correcting the assistant or saying the assistant is wrong", "correction"),
@@ -304,27 +303,27 @@ def _local_classify_sentiment_intent(text: str) -> dict:
304
303
  if cached and cached.get("expires_at", 0) > now:
305
304
  return {k: v for k, v in cached.items() if k != "expires_at"}
306
305
 
307
- global _LOCAL_SENTIMENT_CLASSIFIER
308
306
  try:
309
- if _LOCAL_SENTIMENT_CLASSIFIER is None:
310
- from classifier_local import LocalZeroShotClassifier
311
-
312
- _LOCAL_SENTIMENT_CLASSIFIER = LocalZeroShotClassifier(
313
- confidence_floor=_LOCAL_SENTIMENT_CONFIDENCE_THRESHOLD,
314
- )
315
- if not _LOCAL_SENTIMENT_CLASSIFIER.is_available():
316
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
307
+ from semantic_router import route as semantic_route
308
+ except Exception as exc:
309
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
310
+ try:
317
311
  label_texts = [label for label, _intent in _LOCAL_SENTIMENT_LABELS]
318
312
  intent_by_label = {label: intent for label, intent in _LOCAL_SENTIMENT_LABELS}
319
- result = _LOCAL_SENTIMENT_CLASSIFIER.classify(text, label_texts)
320
- if result is None:
321
- return {"available": False, "label": None, "reason": "classifier_failed"}
313
+ result = semantic_route(
314
+ decision_kind="sentiment_intent",
315
+ question="Classify the operator message sentiment intent.",
316
+ context=text,
317
+ labels=tuple(label_texts),
318
+ )
319
+ if not result.ok:
320
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
322
321
  intent = intent_by_label.get(result.label)
323
322
  payload = {
324
323
  "available": intent in SENTIMENT_INTENTS,
325
324
  "label": intent,
326
325
  "confidence": float(result.confidence or 0.0),
327
- "reason": "local_zero_shot",
326
+ "reason": result.route_used,
328
327
  }
329
328
  _LOCAL_SENTIMENT_CACHE[key] = {
330
329
  **payload,
@@ -332,7 +331,7 @@ def _local_classify_sentiment_intent(text: str) -> dict:
332
331
  }
333
332
  return payload
334
333
  except Exception as exc:
335
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
334
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
336
335
 
337
336
 
338
337
  def detect_sentiment(text: str) -> dict:
@@ -322,6 +322,7 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
322
322
 
323
323
  def _load_map() -> dict | None:
324
324
  for candidate in [
325
+ Path(__file__).parent / MAP_FILENAME,
325
326
  paths.home() / MAP_FILENAME,
326
327
  paths.brain_dir() / MAP_FILENAME,
327
328
  paths.legacy_brain_dir() / MAP_FILENAME,
@@ -613,7 +614,7 @@ class HeadlessEnforcer:
613
614
 
614
615
  correction_detector is an injection point for tests. In production it
615
616
  defaults to r14_correction_learning.detect_correction which routes
616
- through enforcement_classifier + call_model_raw. Fail-closed: a
617
+ through semantic_router. Fail-closed: a
617
618
  broken classifier keeps the window closed (no false positives).
618
619
  """
619
620
  self.user_message_count += 1
@@ -1134,7 +1135,6 @@ class HeadlessEnforcer:
1134
1135
  self._t4_gate_warned = set()
1135
1136
  try:
1136
1137
  from t4_llm_gate import build_prompt, classify_with_llm
1137
- from enforcement_classifier import classify as _classifier_raw
1138
1138
  except Exception as exc:
1139
1139
  key = (rule_id, f"import:{exc.__class__.__name__}")
1140
1140
  if key not in self._t4_gate_warned:
@@ -1147,15 +1147,40 @@ class HeadlessEnforcer:
1147
1147
  )
1148
1148
  return False
1149
1149
 
1150
- # Auditor H1 fix: the legacy bool contract of `classify` collapses
1151
- # "classifier said no" and "classifier response unparseable after
1152
- # two retries — conservative fallback" into the same False, which
1153
- # would silently suppress destructive rules (R23e/R23f/R23h) when
1154
- # the backend responds with garbage. Force the tristate path so
1155
- # "unknown" falls through to regex behaviour instead of becoming
1156
- # a silent rule disable.
1150
+ # Auditor H1 invariant: only an explicit semantic "no" may suppress
1151
+ # a regex hit. Router unavailable/no_route/ambiguous answers fall
1152
+ # through as "unknown" so the original rule still protects us.
1157
1153
  def _classifier_tristate(q: str, ctx: str) -> str:
1158
- return _classifier_raw(q, ctx, tristate=True)
1154
+ decision_kind_by_rule = {
1155
+ "R15": "t4_r15",
1156
+ "R23e": "t4_r23e",
1157
+ "R23f": "t4_r23f",
1158
+ "R23h": "t4_r23h",
1159
+ }
1160
+ decision_kind = decision_kind_by_rule.get(rule_id)
1161
+ if not decision_kind:
1162
+ return "unknown"
1163
+ try:
1164
+ from semantic_router import route as semantic_route
1165
+ except Exception:
1166
+ return "unknown"
1167
+ try:
1168
+ result = semantic_route(
1169
+ decision_kind=decision_kind,
1170
+ question=q,
1171
+ context=ctx,
1172
+ labels=("rule_applies", "false_positive"),
1173
+ )
1174
+ except Exception:
1175
+ return "unknown"
1176
+ if not result.ok:
1177
+ return "unknown"
1178
+ label = result.label or result.verdict
1179
+ if label == "rule_applies":
1180
+ return "yes"
1181
+ if label == "false_positive":
1182
+ return "no"
1183
+ return "unknown"
1159
1184
 
1160
1185
  prompt = build_prompt(rule_id, span=span, context=context)
1161
1186
  if not prompt:
@@ -1885,6 +1910,31 @@ class HeadlessEnforcer:
1885
1910
  if mode == "off":
1886
1911
  return
1887
1912
  recent_names = [r.tool for r in self.recent_tool_records]
1913
+ if classifier is None:
1914
+ def _semantic_classifier(question: str, context: str):
1915
+ try:
1916
+ from semantic_router import route as semantic_route
1917
+ except Exception:
1918
+ return "unknown"
1919
+ try:
1920
+ result = semantic_route(
1921
+ decision_kind="r34_identity_coherence",
1922
+ question=question,
1923
+ context=context,
1924
+ labels=("past_action_denial", "not_a_denial"),
1925
+ )
1926
+ except Exception:
1927
+ return "unknown"
1928
+ if not result.ok:
1929
+ return "unknown"
1930
+ label = result.label or result.verdict
1931
+ if label == "past_action_denial":
1932
+ return "yes"
1933
+ if label == "not_a_denial":
1934
+ return "no"
1935
+ return "unknown"
1936
+
1937
+ classifier = _semantic_classifier
1888
1938
  try:
1889
1939
  inject, prompt, matched = _r34_should(
1890
1940
  text, recent_tool_names=recent_names, classifier=classifier,
@@ -19,7 +19,7 @@ import json
19
19
  from typing import Any, Dict, List, Optional
20
20
 
21
21
 
22
- PLAN_VERSION = 1
22
+ PLAN_VERSION = 2
23
23
 
24
24
 
25
25
  # Actions that trigger a canonical diary+stop plan. `switch` and
@@ -79,6 +79,27 @@ def _diary_prompt_for_action(
79
79
  )
80
80
 
81
81
 
82
+ def _canonical_action(
83
+ action_id: str,
84
+ action_type: str,
85
+ session_id: str,
86
+ timeout_ms: int,
87
+ **extra: Any,
88
+ ) -> Dict[str, Any]:
89
+ """Build one Desktop action with the v2 shape plus one-release mirrors."""
90
+ action: Dict[str, Any] = {
91
+ "id": action_id,
92
+ "type": action_type,
93
+ # Compatibility mirror for Desktop <= 0.28.1. Remove after one
94
+ # release once every supported Desktop consumes `type`.
95
+ "kind": action_type,
96
+ "session_id": str(session_id),
97
+ "timeout_ms": timeout_ms,
98
+ }
99
+ action.update(extra)
100
+ return action
101
+
102
+
82
103
  def build_canonical_plan(
83
104
  event_id: str,
84
105
  action: str,
@@ -106,26 +127,19 @@ def build_canonical_plan(
106
127
  prompt = _diary_prompt_for_action(action, conversation_id, payload_snapshot)
107
128
 
108
129
  actions: List[Dict[str, Any]] = [
109
- {
110
- "id": "a1",
111
- "kind": "resume_session",
112
- "session_id": str(session_id),
113
- "timeout_ms": DEFAULT_RESUME_TIMEOUT_MS,
114
- },
115
- {
116
- "id": "a2",
117
- "kind": "inject_prompt",
118
- "session_id": str(session_id),
119
- "prompt": prompt,
120
- "expected_tool_call": "nexo_session_diary_write",
121
- "timeout_ms": DEFAULT_INJECT_TIMEOUT_MS,
122
- },
123
- {
124
- "id": "a3",
125
- "kind": "stop_session",
126
- "session_id": str(session_id),
127
- "timeout_ms": DEFAULT_STOP_TIMEOUT_MS,
128
- },
130
+ _canonical_action("a1", "resume_session", str(session_id), DEFAULT_RESUME_TIMEOUT_MS),
131
+ _canonical_action(
132
+ "a2",
133
+ "inject_prompt",
134
+ str(session_id),
135
+ DEFAULT_INJECT_TIMEOUT_MS,
136
+ payload={"prompt": prompt},
137
+ # Compatibility mirror for Desktop <= 0.28.1. Remove after one
138
+ # release once every supported Desktop consumes `payload.prompt`.
139
+ prompt=prompt,
140
+ expected_tool_call="nexo_session_diary_write",
141
+ ),
142
+ _canonical_action("a3", "stop_session", str(session_id), DEFAULT_STOP_TIMEOUT_MS),
129
143
  ]
130
144
  return {
131
145
  "canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
@@ -9,10 +9,11 @@ Fase 2 Protocol Enforcer Fase D item R20. Plan doc 1 reads:
9
9
  Decision logic is split in two:
10
10
 
11
11
  1. classify_edit_is_constant_change(file_path, new_string, classifier)
12
- → bool. Uses enforcement_classifier over a prompt that asks whether
13
- the edited region looks like a module-level constant, enum member,
14
- configuration key, or shared global (as opposed to a local variable,
15
- helper body, or doc-string edit).
12
+ → bool. Uses semantic_router decision_kind ``r20_constant_change``
13
+ over a prompt that asks whether the edited region looks like a
14
+ module-level constant, enum member, configuration key, or shared
15
+ global (as opposed to a local variable, helper body, or doc-string
16
+ edit).
16
17
 
17
18
  2. recent_grep_covers_symbol(symbol, recent_tool_records) → bool.
18
19
  Structural — walks the recent tool records (same shape as R13) and
@@ -30,6 +31,8 @@ import re
30
31
  from core_prompts import render_core_prompt
31
32
 
32
33
  CLASSIFIER_QUESTION = render_core_prompt("r20-constant-change-question")
34
+ SEMANTIC_LABELS = ("shared_constant_change", "local_or_non_constant_change")
35
+ POSITIVE_LABEL = "shared_constant_change"
33
36
 
34
37
  INJECTION_PROMPT_TEMPLATE = render_core_prompt(
35
38
  "r20-constant-change-injection",
@@ -62,10 +65,21 @@ def classify_edit_is_constant_change(
62
65
  return False
63
66
  if classifier is None:
64
67
  try:
65
- from enforcement_classifier import classify as classifier # type: ignore
68
+ from semantic_router import route as semantic_route
66
69
  except Exception:
67
70
  return False
68
71
  context = f"File: {file_path}\n\nEdited region:\n{new_string[:400]}"
72
+ if classifier is None:
73
+ try:
74
+ result = semantic_route(
75
+ decision_kind="r20_constant_change",
76
+ question=CLASSIFIER_QUESTION,
77
+ context=context,
78
+ labels=SEMANTIC_LABELS,
79
+ )
80
+ return bool(result.ok and (result.label or result.verdict) == POSITIVE_LABEL)
81
+ except Exception:
82
+ return False
69
83
  try:
70
84
  return bool(classifier(question=CLASSIFIER_QUESTION, context=context))
71
85
  except Exception:
@@ -14,7 +14,7 @@ Detection strategy (two layers):
14
14
  positive rate (any agent message saying "I haven't done X" today
15
15
  would match even when the action is plainly something NEXO has not
16
16
  done).
17
- 2. LLM classifier confirmation (shared with T4). When the regex fires
17
+ 2. Semantic router confirmation. When the regex fires
18
18
  AND no shared-brain tool has been called this turn, the classifier
19
19
  decides whether the message is really a past-tense denial worth
20
20
  nudging. Tests use a fake classifier to avoid hitting the SDK.
@@ -34,8 +34,8 @@ from core_prompts import render_core_prompt
34
34
  def _verdict_to_bool(verdict: Any) -> bool:
35
35
  """Normalize a classifier verdict to an inject/no-inject decision.
36
36
 
37
- The shared ``enforcement_classifier.classify`` exposes a ``tristate``
38
- mode that returns ``"yes"``/``"no"``/``"unknown"`` strings. A naive
37
+ The engine-level semantic router adapter returns ``"yes"``/``"no"``/
38
+ ``"unknown"`` strings. A naive
39
39
  ``bool(verdict)`` wrap treats ``"unknown"`` (and any non-empty string
40
40
  the model may produce) as truthy, which is fail-OPEN for R34. Only
41
41
  a real ``True`` or an explicit ``"yes"`` should trigger an injection;
@@ -122,10 +122,9 @@ def should_inject_r34(
122
122
  return False, "", ""
123
123
  if classifier is None:
124
124
  return True, INJECTION_PROMPT, matched
125
- # LLM disambiguation — reuses T4 infra. The engine passes a lambda
126
- # that calls enforcement_classifier.classify under the hood. Parse
127
- # the verdict via _verdict_to_bool so tristate "unknown" does not
128
- # coerce to True.
125
+ # Semantic disambiguation — the engine passes a lambda that routes
126
+ # through semantic_router. Parse the verdict via _verdict_to_bool so
127
+ # tristate "unknown" does not coerce to True.
129
128
  try:
130
129
  raw_verdict = classifier(CLASSIFIER_QUESTION, message)
131
130
  except Exception:
@@ -159,30 +159,8 @@ def _classifier_requires_operator_attention(text: str, operator_name: str = "")
159
159
  clean_text = " ".join(str(text or "").split())
160
160
  if len(clean_text) < 12:
161
161
  return None
162
- try:
163
- from classifier_local import LocalZeroShotClassifier
164
- except Exception:
165
- return None
166
-
167
- classifier = LocalZeroShotClassifier(confidence_floor=0.72)
168
- if not classifier.is_available():
169
- return None
170
162
 
171
163
  needs_attention, can_continue, waiting_external = _operator_attention_label_set(operator_name)
172
- result = classifier.classify(clean_text, labels=(needs_attention, can_continue, waiting_external))
173
- if result is None or result.confidence < 0.72:
174
- return None
175
- if result.label == needs_attention:
176
- return True
177
- if result.label in {can_continue, waiting_external}:
178
- return False
179
- return None
180
-
181
-
182
- def _llm_requires_operator_attention(text: str, operator_name: str = "") -> bool | None:
183
- clean_text = " ".join(str(text or "").split())
184
- if len(clean_text) < 24:
185
- return None
186
164
  clean_operator = " ".join(str(operator_name or "").split()).strip()
187
165
  subject = clean_operator if clean_operator else "the operator"
188
166
  question = render_core_prompt(
@@ -194,22 +172,32 @@ def _llm_requires_operator_attention(text: str, operator_name: str = "") -> bool
194
172
  pending_item=clean_text,
195
173
  )
196
174
  try:
197
- from enforcement_classifier import ClassifierUnavailableError, classify
175
+ from semantic_router import route as semantic_route
198
176
  except Exception:
199
177
  return None
200
178
  try:
201
- verdict = classify(question, context=context, tier="muy_bajo", tristate=True)
202
- except ClassifierUnavailableError:
203
- return None
179
+ result = semantic_route(
180
+ decision_kind="followup_operator_attention",
181
+ question=question,
182
+ context=context,
183
+ labels=(needs_attention, can_continue, waiting_external),
184
+ )
204
185
  except Exception:
205
186
  return None
206
- if verdict == "yes":
187
+ if not result.ok:
188
+ return None
189
+ label = result.label or result.verdict
190
+ if label == needs_attention:
207
191
  return True
208
- if verdict == "no":
192
+ if label in {can_continue, waiting_external}:
209
193
  return False
210
194
  return None
211
195
 
212
196
 
197
+ def _llm_requires_operator_attention(text: str, operator_name: str = "") -> bool | None:
198
+ return _classifier_requires_operator_attention(text, operator_name=operator_name)
199
+
200
+
213
201
  def _followup_needs_operator_attention(followup: dict, operator_name: str = "") -> bool:
214
202
  status = str(followup.get("status") or "").strip().lower()
215
203
  owner = str(followup.get("owner") or "").strip().lower()
@@ -94,7 +94,6 @@ RESOLUTION_PATTERNS = (
94
94
  )
95
95
 
96
96
  _REPLY_EVENT_CONFIDENCE = float(os.environ.get("NEXO_REPLY_EVENT_CONFIDENCE", "0.72"))
97
- _LOCAL_REPLY_EVENT_CLASSIFIER = None
98
97
  _REPLY_EVENT_LABELS = (
99
98
  ("The reply acknowledges receipt or says the work starts now", "ack"),
100
99
  ("The reply makes a future commitment or promises an update later", "commitment"),
@@ -180,20 +179,20 @@ def _classify_reply_event_semantically(body_text):
180
179
  if len(text) < 20:
181
180
  return None
182
181
 
183
- global _LOCAL_REPLY_EVENT_CLASSIFIER
184
182
  try:
185
- if _LOCAL_REPLY_EVENT_CLASSIFIER is None:
186
- from classifier_local import LocalZeroShotClassifier
187
-
188
- _LOCAL_REPLY_EVENT_CLASSIFIER = LocalZeroShotClassifier(
189
- confidence_floor=_REPLY_EVENT_CONFIDENCE,
190
- )
191
- if not _LOCAL_REPLY_EVENT_CLASSIFIER.is_available():
192
- return None
183
+ from semantic_router import route as semantic_route
184
+ except Exception:
185
+ return None
186
+ try:
193
187
  label_texts = [label for label, _canonical in _REPLY_EVENT_LABELS]
194
188
  canonical_by_label = {label: canonical for label, canonical in _REPLY_EVENT_LABELS}
195
- result = _LOCAL_REPLY_EVENT_CLASSIFIER.classify(text, label_texts)
196
- if result is None:
189
+ result = semantic_route(
190
+ decision_kind="reply_event_type",
191
+ question="Classify the email reply lifecycle event.",
192
+ context=text,
193
+ labels=tuple(label_texts),
194
+ )
195
+ if not result.ok:
197
196
  return None
198
197
  if float(result.confidence or 0.0) < _REPLY_EVENT_CONFIDENCE:
199
198
  return None
@@ -8,7 +8,6 @@ heartbeat, task_close, and diary consolidation.
8
8
  import json
9
9
  import os
10
10
  import re
11
- import subprocess
12
11
  import time
13
12
  import unicodedata
14
13
  from core_prompts import render_core_prompt
@@ -30,29 +29,14 @@ _SEMANTIC_THRESHOLD = 0.75
30
29
  _SEMANTIC_MARGIN = 0.15
31
30
  _LLM_MIN_TEXT_CHARS = int(os.environ.get("NEXO_DRIVE_LLM_MIN_CHARS", "24"))
32
31
  _LOCAL_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_LOCAL_CONFIDENCE", "0.72"))
33
- _LLM_TIMEOUT_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_TIMEOUT", "20"))
34
32
  _LLM_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_LLM_CONFIDENCE", "0.62"))
35
33
  _LLM_CACHE_TTL_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_CACHE_TTL", "21600"))
36
- _LLM_ALLOWED_LABELS = {"anomaly", "pattern", "gap", "opportunity", "none"}
37
34
  _LLM_CLASSIFICATION_CACHE: dict[str, dict] = {}
38
- _AREA_LLM_ALLOWED_LABELS = {
39
- "shopify",
40
- "google-ads",
41
- "meta-ads",
42
- "wazion",
43
- "nexo",
44
- "canaririural",
45
- "seo",
46
- "email",
47
- "none",
48
- }
49
35
  _LLM_AREA_CLASSIFICATION_CACHE: dict[str, dict] = {}
50
36
  _LOCAL_ALLOWED_LABELS = ("anomaly", "pattern", "gap", "opportunity", "none")
51
- _LOCAL_SIGNAL_CLASSIFIER = None
52
37
  _AREA_SCORE_THRESHOLD = 0.64
53
38
  _AREA_SCORE_MARGIN = 0.14
54
39
  _AREA_LOCAL_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_AREA_LOCAL_CONFIDENCE", "0.66"))
55
- _LOCAL_AREA_CLASSIFIER = None
56
40
 
57
41
  _SIGNAL_CUES = {
58
42
  "anomaly": {
@@ -323,29 +307,29 @@ def _local_classify_signal(text: str) -> dict:
323
307
  if len(text_norm) < _LLM_MIN_TEXT_CHARS:
324
308
  return {"available": False, "label": None, "reason": "text_too_short"}
325
309
 
326
- global _LOCAL_SIGNAL_CLASSIFIER
327
310
  try:
328
- if _LOCAL_SIGNAL_CLASSIFIER is None:
329
- from classifier_local import LocalZeroShotClassifier
330
-
331
- _LOCAL_SIGNAL_CLASSIFIER = LocalZeroShotClassifier(
332
- confidence_floor=_LOCAL_CONFIDENCE_THRESHOLD,
333
- )
334
- if not _LOCAL_SIGNAL_CLASSIFIER.is_available():
335
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
336
- result = _LOCAL_SIGNAL_CLASSIFIER.classify(text, _LOCAL_ALLOWED_LABELS)
337
- if result is None:
338
- return {"available": False, "label": None, "reason": "classifier_failed"}
311
+ from semantic_router import route as semantic_route
312
+ except Exception as exc:
313
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
314
+ try:
315
+ result = semantic_route(
316
+ decision_kind="drive_signal_type",
317
+ question=render_core_prompt("drive-signal-classifier-system"),
318
+ context=text.strip()[:3000],
319
+ labels=_LOCAL_ALLOWED_LABELS,
320
+ )
321
+ if not result.ok:
322
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
339
323
  label = result.label if result.label in _LOCAL_ALLOWED_LABELS else None
340
324
  return {
341
325
  "available": label is not None,
342
326
  "label": None if label == "none" else label,
343
327
  "confidence": float(result.confidence or 0.0),
344
- "reason": "local_zero_shot",
345
- "source": "local",
328
+ "reason": result.route_used,
329
+ "source": "semantic_router",
346
330
  }
347
331
  except Exception as exc:
348
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
332
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
349
333
 
350
334
 
351
335
  def _llm_classify_signal(text: str) -> dict:
@@ -359,54 +343,7 @@ def _llm_classify_signal(text: str) -> dict:
359
343
  if cached and cached.get("expires_at", 0) > now:
360
344
  return {k: v for k, v in cached.items() if k != "expires_at"}
361
345
 
362
- try:
363
- from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
364
- except Exception as exc:
365
- return {"available": False, "label": None, "reason": f"runner_unavailable:{exc}"}
366
-
367
- json_system_prompt = render_core_prompt("drive-signal-classifier-system")
368
- prompt = render_core_prompt(
369
- "drive-signal-classifier-user",
370
- text=text.strip()[:3000],
371
- )
372
-
373
- try:
374
- result = run_automation_prompt(
375
- prompt,
376
- caller="tools/drive_search",
377
- task_profile="fast",
378
- timeout=_LLM_TIMEOUT_SECONDS,
379
- output_format="text",
380
- append_system_prompt=json_system_prompt,
381
- )
382
- except (AutomationBackendUnavailableError, subprocess.TimeoutExpired) as exc:
383
- return {"available": False, "label": None, "reason": f"automation_unavailable:{exc}"}
384
- except Exception as exc:
385
- return {"available": False, "label": None, "reason": f"automation_error:{exc}"}
386
-
387
- if result.returncode != 0:
388
- return {"available": False, "label": None, "reason": f"automation_returncode:{result.returncode}"}
389
-
390
- parsed = _extract_json_object(result.stdout)
391
- if not parsed:
392
- return {"available": False, "label": None, "reason": "invalid_json"}
393
-
394
- label = str(parsed.get("label", "") or "").strip().lower()
395
- if label not in _LLM_ALLOWED_LABELS:
396
- return {"available": False, "label": None, "reason": "invalid_label"}
397
-
398
- try:
399
- confidence = float(parsed.get("confidence", 0.0) or 0.0)
400
- except (TypeError, ValueError):
401
- confidence = 0.0
402
-
403
- classification = {
404
- "available": True,
405
- "label": None if label == "none" else label,
406
- "confidence": confidence,
407
- "reason": str(parsed.get("reason", "") or ""),
408
- "source": "llm",
409
- }
346
+ classification = _local_classify_signal(text)
410
347
  _LLM_CLASSIFICATION_CACHE[cache_key] = {
411
348
  **classification,
412
349
  "expires_at": now + _LLM_CACHE_TTL_SECONDS,
@@ -425,54 +362,7 @@ def _llm_classify_area(text: str) -> dict:
425
362
  if cached and cached.get("expires_at", 0) > now:
426
363
  return {k: v for k, v in cached.items() if k != "expires_at"}
427
364
 
428
- try:
429
- from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
430
- except Exception as exc:
431
- return {"available": False, "label": None, "reason": f"runner_unavailable:{exc}"}
432
-
433
- json_system_prompt = render_core_prompt("drive-area-classifier-system")
434
- prompt = render_core_prompt(
435
- "drive-area-classifier-user",
436
- text=text.strip()[:3000],
437
- )
438
-
439
- try:
440
- result = run_automation_prompt(
441
- prompt,
442
- caller="tools/drive_area",
443
- task_profile="fast",
444
- timeout=_LLM_TIMEOUT_SECONDS,
445
- output_format="text",
446
- append_system_prompt=json_system_prompt,
447
- )
448
- except (AutomationBackendUnavailableError, subprocess.TimeoutExpired) as exc:
449
- return {"available": False, "label": None, "reason": f"automation_unavailable:{exc}"}
450
- except Exception as exc:
451
- return {"available": False, "label": None, "reason": f"automation_error:{exc}"}
452
-
453
- if result.returncode != 0:
454
- return {"available": False, "label": None, "reason": f"automation_returncode:{result.returncode}"}
455
-
456
- parsed = _extract_json_object(result.stdout)
457
- if not parsed:
458
- return {"available": False, "label": None, "reason": "invalid_json"}
459
-
460
- label = str(parsed.get("label", "") or "").strip().lower()
461
- if label not in _AREA_LLM_ALLOWED_LABELS:
462
- return {"available": False, "label": None, "reason": "invalid_label"}
463
-
464
- try:
465
- confidence = float(parsed.get("confidence", 0.0) or 0.0)
466
- except (TypeError, ValueError):
467
- confidence = 0.0
468
-
469
- classification = {
470
- "available": True,
471
- "label": None if label == "none" else label,
472
- "confidence": confidence,
473
- "reason": str(parsed.get("reason", "") or ""),
474
- "source": "llm",
475
- }
365
+ classification = _local_classify_area(text)
476
366
  _LLM_AREA_CLASSIFICATION_CACHE[cache_key] = {
477
367
  **classification,
478
368
  "expires_at": now + _LLM_CACHE_TTL_SECONDS,
@@ -554,31 +444,31 @@ def _local_classify_area(text: str) -> dict:
554
444
  if len(text_norm) < _LLM_MIN_TEXT_CHARS:
555
445
  return {"available": False, "label": None, "reason": "text_too_short"}
556
446
 
557
- global _LOCAL_AREA_CLASSIFIER
558
447
  try:
559
- if _LOCAL_AREA_CLASSIFIER is None:
560
- from classifier_local import LocalZeroShotClassifier
561
-
562
- _LOCAL_AREA_CLASSIFIER = LocalZeroShotClassifier(
563
- confidence_floor=_AREA_LOCAL_CONFIDENCE_THRESHOLD,
564
- )
565
- if not _LOCAL_AREA_CLASSIFIER.is_available():
566
- return {"available": False, "label": None, "reason": "classifier_unavailable"}
448
+ from semantic_router import route as semantic_route
449
+ except Exception as exc:
450
+ return {"available": False, "label": None, "reason": f"router_unavailable:{exc}"}
451
+ try:
567
452
  label_texts = [label for label, _canonical in _AREA_LOCAL_LABELS]
568
453
  canonical_by_label = {label: canonical for label, canonical in _AREA_LOCAL_LABELS}
569
- result = _LOCAL_AREA_CLASSIFIER.classify(text, label_texts)
570
- if result is None:
571
- return {"available": False, "label": None, "reason": "classifier_failed"}
454
+ result = semantic_route(
455
+ decision_kind="drive_area",
456
+ question=render_core_prompt("drive-area-classifier-system"),
457
+ context=text.strip()[:3000],
458
+ labels=tuple(label_texts),
459
+ )
460
+ if not result.ok:
461
+ return {"available": False, "label": None, "reason": result.error or "router_no_route"}
572
462
  canonical = canonical_by_label.get(result.label)
573
463
  return {
574
464
  "available": canonical is not None,
575
465
  "label": None if canonical == "none" else canonical,
576
466
  "confidence": float(result.confidence or 0.0),
577
- "reason": "local_zero_shot",
578
- "source": "local",
467
+ "reason": result.route_used,
468
+ "source": "semantic_router",
579
469
  }
580
470
  except Exception as exc:
581
- return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
471
+ return {"available": False, "label": None, "reason": f"router_error:{exc}"}
582
472
 
583
473
 
584
474
  def _legacy_keyword_area(text: str) -> str: