nexo-brain 7.1.4 → 7.1.7

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.
Files changed (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/bin/postinstall.js +2 -2
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +4 -32
  6. package/src/auto_update.py +120 -1
  7. package/src/automation_controls.py +2 -1
  8. package/src/autonomy_mandate.py +17 -2
  9. package/src/bootstrap_docs.py +2 -1
  10. package/src/calibration_runtime.py +46 -0
  11. package/src/claude_cli.py +151 -0
  12. package/src/cli.py +114 -0
  13. package/src/client_preferences.py +84 -1
  14. package/src/client_sync.py +48 -2
  15. package/src/cognitive/_search.py +109 -10
  16. package/src/cognitive/_trust.py +117 -0
  17. package/src/core_schedule_controls.py +494 -0
  18. package/src/cron_recovery.py +11 -2
  19. package/src/crons/sync.py +10 -1
  20. package/src/db/__init__.py +1 -0
  21. package/src/db/_learnings.py +21 -9
  22. package/src/db/_protocol.py +33 -2
  23. package/src/db/_reminders.py +23 -13
  24. package/src/db/_schema.py +4 -0
  25. package/src/db/_semantic_similarity.py +98 -0
  26. package/src/db/_skills.py +28 -18
  27. package/src/doctor/providers/runtime.py +54 -9
  28. package/src/email_config.py +5 -0
  29. package/src/enforcement_engine.py +151 -2
  30. package/src/guard_verbal_ack.py +66 -0
  31. package/src/hook_guardrails.py +240 -27
  32. package/src/hooks/auto_capture.py +32 -7
  33. package/src/hooks/post_tool_use.py +8 -5
  34. package/src/paths.py +9 -0
  35. package/src/plugin_loader.py +65 -27
  36. package/src/plugins/core_rules.py +30 -1
  37. package/src/plugins/cortex.py +4 -1
  38. package/src/plugins/doctor.py +15 -0
  39. package/src/plugins/episodic_memory.py +52 -2
  40. package/src/plugins/evolution.py +22 -0
  41. package/src/plugins/guard.py +282 -26
  42. package/src/plugins/personal_scripts.py +116 -15
  43. package/src/plugins/protocol.py +358 -55
  44. package/src/plugins/skills.py +11 -2
  45. package/src/product_mode.py +28 -6
  46. package/src/scripts/check-context.py +3 -2
  47. package/src/scripts/nexo-catchup.py +6 -25
  48. package/src/scripts/nexo-daily-self-audit.py +0 -21
  49. package/src/scripts/nexo-email-monitor.py +51 -10
  50. package/src/scripts/nexo-evolution-run.py +5 -22
  51. package/src/scripts/nexo-postmortem-consolidator.py +0 -21
  52. package/src/scripts/nexo-send-reply.py +40 -0
  53. package/src/scripts/nexo-sleep.py +0 -20
  54. package/src/scripts/nexo-synthesis.py +0 -21
  55. package/src/scripts/nexo-watchdog.sh +28 -30
  56. package/src/server.py +4 -86
  57. package/src/session_end_intent.py +31 -0
  58. package/src/skills/create-nexo-primitive/guide.md +87 -0
  59. package/src/skills/create-nexo-primitive/skill.json +62 -0
  60. package/src/tools_drive.py +277 -12
  61. package/src/tools_email_guard.py +74 -0
  62. package/src/tools_hot_context.py +11 -2
  63. package/src/tools_sessions.py +61 -9
  64. package/src/tools_system_catalog.py +2 -2
  65. package/src/user_context.py +2 -1
  66. package/templates/CLAUDE.md.template +1 -1
  67. package/templates/CODEX.AGENTS.md.template +1 -1
  68. package/templates/core-prompts/automation-backend-probe.md +1 -0
  69. package/templates/core-prompts/autonomy-mandate-question.md +6 -0
  70. package/templates/core-prompts/codex-protocol-contract.md +7 -0
  71. package/templates/core-prompts/drive-area-classifier-system.md +4 -0
  72. package/templates/core-prompts/drive-area-classifier-user.md +6 -0
  73. package/templates/core-prompts/email-monitor.md +2 -0
  74. package/templates/core-prompts/guard-verbal-ack-question.md +1 -0
  75. package/templates/core-prompts/heartbeat-diary-overdue.md +1 -0
  76. package/templates/core-prompts/heartbeat-guard-reminder.md +1 -0
  77. package/templates/core-prompts/heartbeat-learning-reminder.md +1 -0
  78. package/templates/core-prompts/hook-protocol-warning-guard-required.md +1 -0
  79. package/templates/core-prompts/hook-protocol-warning-heartbeat-close-evidence.md +1 -0
  80. package/templates/core-prompts/hook-protocol-warning-startup-required.md +1 -0
  81. package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -0
  82. package/templates/core-prompts/hook-protocol-warning-task-open-guard-note.md +1 -0
  83. package/templates/core-prompts/hook-protocol-warning-task-open-required.md +1 -0
  84. package/templates/core-prompts/hook-protocol-warning-workflow-required.md +1 -0
  85. package/templates/core-prompts/post-tool-inbox-reminder.md +1 -0
  86. package/templates/core-prompts/server-mcp-instructions.md +38 -0
  87. package/templates/core-prompts/session-end-intent-question.md +1 -0
  88. package/templates/core-prompts/watchdog-repair.md +25 -0
  89. package/templates/launchagents/README.md +1 -1
  90. package/templates/launchagents/com.nexo.synthesis.plist +8 -3
package/src/cli.py CHANGED
@@ -700,6 +700,83 @@ def _automations_set_schedule(args):
700
700
  return 0
701
701
 
702
702
 
703
+ def _core_schedules_list(args):
704
+ from core_schedule_controls import list_core_schedules
705
+
706
+ rows = list_core_schedules()
707
+ if args.json:
708
+ print(json.dumps({"ok": True, "core_schedules": rows}, indent=2, ensure_ascii=False))
709
+ return 0
710
+ if not rows:
711
+ print("No core schedules available.")
712
+ return 0
713
+ for row in rows:
714
+ schedule = str(row.get("effective_schedule_label") or "—")
715
+ note = str(row.get("note") or "").strip()
716
+ extra = f" · {note}" if note else ""
717
+ print(f"{row.get('name')} · {schedule}{extra}")
718
+ return 0
719
+
720
+
721
+ def _core_schedules_status(args):
722
+ from core_schedule_controls import get_core_schedule_status
723
+
724
+ result = get_core_schedule_status(args.name)
725
+ if args.json:
726
+ print(json.dumps(result, indent=2, ensure_ascii=False))
727
+ return 0 if result.get("ok") else 1
728
+ if not result.get("ok"):
729
+ print(result.get("error", "Failed to read core schedule"), file=sys.stderr)
730
+ return 1
731
+ print(f"{result.get('name')} -> {result.get('effective_schedule_label') or '—'}")
732
+ default_label = (result.get("default_schedule_label") or "").strip()
733
+ if default_label:
734
+ print(f" default: {default_label}")
735
+ note = (result.get("note") or "").strip()
736
+ if note:
737
+ print(f" note: {note}")
738
+ return 0
739
+
740
+
741
+ def _core_schedules_set(args):
742
+ from core_schedule_controls import set_core_schedule
743
+
744
+ interval_seconds = None
745
+ daily_at = None
746
+ if getattr(args, "every_minutes", None) is not None:
747
+ interval_seconds = int(args.every_minutes) * 60
748
+ elif getattr(args, "every_seconds", None) is not None:
749
+ interval_seconds = int(args.every_seconds)
750
+ elif getattr(args, "daily_at", None):
751
+ daily_at = str(args.daily_at).strip()
752
+
753
+ result = set_core_schedule(
754
+ args.name,
755
+ interval_seconds=interval_seconds,
756
+ daily_at=daily_at,
757
+ clear=bool(getattr(args, "reset", False)),
758
+ )
759
+ if args.json:
760
+ print(json.dumps(result, indent=2, ensure_ascii=False))
761
+ return 0 if result.get("ok") else 1
762
+ if not result.get("ok"):
763
+ print(result.get("error", "Failed to update core schedule"), file=sys.stderr)
764
+ return 1
765
+ label = str(result.get("effective_schedule_label") or "").strip()
766
+ source = str(result.get("schedule_source") or "manifest").strip()
767
+ if label:
768
+ print(f"Core schedule updated for {result['name']}: {label} ({source})")
769
+ else:
770
+ print(f"Core schedule updated for {result['name']}.")
771
+ warning = str(result.get("warning") or "").strip()
772
+ if warning:
773
+ print(f" Warning: {warning}")
774
+ sync_result = result.get("runtime_sync") if isinstance(result.get("runtime_sync"), dict) else {}
775
+ if sync_result.get("ok") is False:
776
+ print(f" Warning: saved, but runtime sync failed: {sync_result.get('error', 'unknown error')}")
777
+ return 0
778
+
779
+
703
780
  def _scripts_remove(args):
704
781
  from script_registry import remove_personal_script
705
782
 
@@ -976,6 +1053,12 @@ def _scripts_call(args):
976
1053
  print(f"Invalid JSON input: {e}", file=sys.stderr)
977
1054
  return 1
978
1055
 
1056
+ # Legacy `scripts call nexo_doctor` callers predate the explicit doctor-plane
1057
+ # contract. Keep the plugin strict, but default the CLI compatibility surface
1058
+ # to the install/runtime plane that old callers implicitly meant.
1059
+ if tool_name == "nexo_doctor" and isinstance(payload, dict) and not str(payload.get("plane") or "").strip():
1060
+ payload["plane"] = "installation_live"
1061
+
979
1062
  def _bootstrap_mcp():
980
1063
  os.environ["NEXO_CLI_MODE"] = "1"
981
1064
  from db import init_db
@@ -2541,6 +2624,28 @@ def main():
2541
2624
  automations_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
2542
2625
  automations_schedule_p.add_argument("--json", action="store_true", help="JSON output")
2543
2626
 
2627
+ core_schedules_parser = sub.add_parser("core-schedules", help="Manage structural core cron cadences")
2628
+ core_schedules_sub = core_schedules_parser.add_subparsers(dest="core_schedules_command")
2629
+
2630
+ core_schedules_list_p = core_schedules_sub.add_parser("list", help="List structural core schedules")
2631
+ core_schedules_list_p.add_argument("--json", action="store_true", help="JSON output")
2632
+
2633
+ core_schedules_status_p = core_schedules_sub.add_parser("status", help="Read one structural core schedule")
2634
+ core_schedules_status_p.add_argument("name", help="Core cron name")
2635
+ core_schedules_status_p.add_argument("--json", action="store_true", help="JSON output")
2636
+
2637
+ core_schedules_schedule_p = core_schedules_sub.add_parser(
2638
+ "schedule",
2639
+ help="Change the cadence of a structural core cron",
2640
+ )
2641
+ core_schedules_schedule_p.add_argument("name", help="Core cron name")
2642
+ core_schedules_schedule_group = core_schedules_schedule_p.add_mutually_exclusive_group(required=True)
2643
+ core_schedules_schedule_group.add_argument("--every-minutes", type=int, help="Run the cron every N minutes")
2644
+ core_schedules_schedule_group.add_argument("--every-seconds", type=int, help="Run the cron every N seconds")
2645
+ core_schedules_schedule_group.add_argument("--daily-at", type=str, help="Run the cron at HH:MM and preserve any weekday")
2646
+ core_schedules_schedule_group.add_argument("--reset", action="store_true", help="Restore the shipped default cadence")
2647
+ core_schedules_schedule_p.add_argument("--json", action="store_true", help="JSON output")
2648
+
2544
2649
  # -- update --
2545
2650
  update_parser = sub.add_parser("update", help="Update installed runtime")
2546
2651
  update_parser.add_argument("--json", action="store_true", help="JSON output")
@@ -2846,6 +2951,15 @@ def main():
2846
2951
  return _automations_set_schedule(args)
2847
2952
  automations_parser.print_help()
2848
2953
  return 0
2954
+ elif args.command == "core-schedules":
2955
+ if args.core_schedules_command == "list":
2956
+ return _core_schedules_list(args)
2957
+ elif args.core_schedules_command == "status":
2958
+ return _core_schedules_status(args)
2959
+ elif args.core_schedules_command == "schedule":
2960
+ return _core_schedules_set(args)
2961
+ core_schedules_parser.print_help()
2962
+ return 0
2849
2963
  elif args.command == "chat":
2850
2964
  return _chat(args)
2851
2965
  elif args.command == "export":
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  """Client and automation preference helpers stored in config/schedule.json."""
4
4
 
5
5
  import os
6
+ import json
6
7
  import shutil
7
8
  import sys
8
9
  from pathlib import Path
@@ -74,6 +75,85 @@ def _codex_bootstrap_path(home: Path) -> Path:
74
75
  return home / ".codex" / "AGENTS.md"
75
76
 
76
77
 
78
+ def _desktop_product_requested(home: Path | None = None) -> bool:
79
+ if str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1":
80
+ return True
81
+ explicit_home = home is not None
82
+ base = (home or _user_home()).expanduser()
83
+ mode_paths = (
84
+ base / ".nexo" / "personal" / "config" / "product-mode.json",
85
+ base / ".nexo" / "config" / "product-mode.json",
86
+ )
87
+ for mode_path in mode_paths:
88
+ try:
89
+ payload = json.loads(mode_path.read_text())
90
+ except Exception:
91
+ continue
92
+ if not isinstance(payload, dict):
93
+ continue
94
+ if payload.get("desktop_managed") is True:
95
+ return True
96
+ if str(payload.get("product_mode") or "").strip().lower() == "desktop_closed_product":
97
+ return True
98
+ desktop_markers = [
99
+ base / "Applications" / "NEXO Desktop.app",
100
+ base / "Library" / "Application Support" / "NEXO Desktop",
101
+ base / ".local" / "share" / "NEXO Desktop",
102
+ base / ".config" / "NEXO Desktop",
103
+ ]
104
+ if not explicit_home:
105
+ desktop_markers.insert(0, Path("/Applications/NEXO Desktop.app"))
106
+ for marker in desktop_markers:
107
+ try:
108
+ if marker.exists():
109
+ return True
110
+ except Exception:
111
+ continue
112
+ return False
113
+
114
+
115
+ def _managed_claude_prefix(home: Path | None = None) -> Path:
116
+ explicit = str(os.environ.get("NEXO_CLAUDE_PREFIX", "")).strip()
117
+ if explicit:
118
+ return Path(explicit).expanduser()
119
+ return (home or _user_home()) / ".nexo" / "runtime" / "bootstrap" / "npm-global"
120
+
121
+
122
+ def _path_within(candidate: Path, parent: Path) -> bool:
123
+ try:
124
+ candidate.expanduser().resolve(strict=False).relative_to(parent.expanduser().resolve(strict=False))
125
+ return True
126
+ except Exception:
127
+ return False
128
+
129
+
130
+ def _managed_claude_binary(home: Path | None = None) -> str:
131
+ base = (home or _user_home()).expanduser()
132
+ managed_prefix = _managed_claude_prefix(base)
133
+ persisted_paths = [
134
+ base / ".nexo" / "config" / "claude-cli-path",
135
+ base / ".nexo" / "personal" / "config" / "claude-cli-path",
136
+ ]
137
+ candidates: list[Path] = []
138
+ for persisted in persisted_paths:
139
+ try:
140
+ raw = persisted.read_text(encoding="utf-8").strip()
141
+ except Exception:
142
+ raw = ""
143
+ if raw:
144
+ candidates.append(Path(raw))
145
+ candidates.append(managed_prefix / "bin" / "claude")
146
+ for candidate in candidates:
147
+ try:
148
+ if not candidate.exists():
149
+ continue
150
+ except Exception:
151
+ continue
152
+ if _path_within(candidate, managed_prefix):
153
+ return str(candidate)
154
+ return ""
155
+
156
+
77
157
  def _coerce_bool(value, default: bool) -> bool:
78
158
  if isinstance(value, bool):
79
159
  return value
@@ -584,7 +664,10 @@ def _which_with_nvm(name: str, home: Path | None = None) -> str:
584
664
  def detect_installed_clients(user_home: str | os.PathLike[str] | None = None) -> dict[str, dict]:
585
665
  home = Path(user_home).expanduser() if user_home else _user_home()
586
666
 
587
- claude_bin = os.environ.get("CLAUDE_BIN", "").strip() or _which_with_nvm("claude", home)
667
+ if _desktop_product_requested(home):
668
+ claude_bin = _managed_claude_binary(home)
669
+ else:
670
+ claude_bin = os.environ.get("CLAUDE_BIN", "").strip() or _which_with_nvm("claude", home)
588
671
  codex_bin = os.environ.get("CODEX_BIN", "").strip() or _which_with_nvm("codex", home)
589
672
 
590
673
  if sys.platform == "darwin":
@@ -12,6 +12,8 @@ import subprocess
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+ from calibration_runtime import load_runtime_calibration
16
+
15
17
  try:
16
18
  import tomllib
17
19
  except ModuleNotFoundError: # Python < 3.11
@@ -117,7 +119,7 @@ def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
117
119
  env_name = os.environ.get("NEXO_NAME", "").strip()
118
120
  if env_name:
119
121
  return env_name
120
- calibration = _read_json_file(nexo_home / "personal" / "brain" / "calibration.json")
122
+ calibration = load_runtime_calibration(nexo_home / "personal" / "brain" / "calibration.json")
121
123
  user_payload = calibration.get("user")
122
124
  if isinstance(user_payload, dict):
123
125
  candidate = str(user_payload.get("assistant_name", "")).strip()
@@ -190,6 +192,28 @@ def _managed_claude_prefix(user_home: Path | None = None) -> Path:
190
192
  return home / ".nexo" / "runtime" / "bootstrap" / "npm-global"
191
193
 
192
194
 
195
+ def _desktop_product_requested(user_home: Path | None = None) -> bool:
196
+ if str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1":
197
+ return True
198
+ home = (user_home or _user_home()).expanduser()
199
+ mode_paths = [
200
+ home / ".nexo" / "personal" / "config" / "product-mode.json",
201
+ home / ".nexo" / "config" / "product-mode.json",
202
+ ]
203
+ for path in mode_paths:
204
+ try:
205
+ payload = json.loads(path.read_text())
206
+ except Exception:
207
+ continue
208
+ if not isinstance(payload, dict):
209
+ continue
210
+ if payload.get("desktop_managed") is True:
211
+ return True
212
+ if str(payload.get("product_mode") or "").strip().lower() == "desktop_closed_product":
213
+ return True
214
+ return False
215
+
216
+
193
217
  def _bundled_npm_runtime() -> tuple[str, str]:
194
218
  node_bin = str(os.environ.get("NEXO_DESKTOP_NODE", "")).strip()
195
219
  npm_cli = str(os.environ.get("NEXO_DESKTOP_NPM_CLI", "")).strip()
@@ -224,6 +248,7 @@ def _installed_client_path(client_key: str, *, user_home: Path | None = None) ->
224
248
 
225
249
  def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = None) -> dict:
226
250
  home_path = Path(user_home).expanduser() if user_home else _user_home()
251
+ desktop_managed = _desktop_product_requested(home_path)
227
252
  existing = _installed_client_path("claude_code", user_home=home_path)
228
253
  if existing:
229
254
  _persist_managed_claude_path(existing, user_home=home_path)
@@ -232,7 +257,7 @@ def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = N
232
257
  "client": "claude_code",
233
258
  "installed": True,
234
259
  "changed": False,
235
- "action": "already_installed",
260
+ "action": "already_installed_managed" if desktop_managed else "already_installed",
236
261
  "path": existing,
237
262
  "attempts": [],
238
263
  }
@@ -281,6 +306,27 @@ def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = N
281
306
  "attempts": attempts,
282
307
  }
283
308
 
309
+ if desktop_managed:
310
+ error = (
311
+ "Desktop-managed install requires the NEXO Desktop bundled Claude runtime; "
312
+ "global `npx`/`npm -g` fallbacks are disabled."
313
+ )
314
+ if desktop_node and bundled_npm_cli:
315
+ error = (
316
+ "Desktop-managed Claude install did not produce the managed "
317
+ "`~/.nexo/runtime/bootstrap/npm-global/bin/claude` binary."
318
+ )
319
+ return {
320
+ "ok": False,
321
+ "client": "claude_code",
322
+ "installed": False,
323
+ "changed": False,
324
+ "action": "managed_install_failed",
325
+ "path": "",
326
+ "attempts": attempts,
327
+ "error": error,
328
+ }
329
+
284
330
  try:
285
331
  probe = subprocess.run(
286
332
  ["npx", "-y", CLAUDE_CODE_NPM_PACKAGE, "--version"],
@@ -1,7 +1,9 @@
1
1
  """NEXO Cognitive — Search, retrieval, ranking."""
2
2
  import math
3
+ import os
3
4
  import re
4
5
  import sqlite3
6
+ import time
5
7
  import numpy as np
6
8
  from datetime import datetime, timezone
7
9
 
@@ -17,6 +19,92 @@ from cognitive._core import (
17
19
  rehearsal_profile_update,
18
20
  )
19
21
 
22
+ _QUERY_INTENT_SCORE_THRESHOLD = 0.72
23
+ _QUERY_INTENT_SCORE_MARGIN = 0.14
24
+ _QUERY_INTENT_LOCAL_CONFIDENCE_THRESHOLD = float(
25
+ os.environ.get("NEXO_QUERY_INTENT_LOCAL_CONFIDENCE", "0.67")
26
+ )
27
+ _QUERY_INTENT_CACHE_TTL_SECONDS = int(
28
+ os.environ.get("NEXO_QUERY_INTENT_LOCAL_CACHE_TTL", "21600")
29
+ )
30
+ _LOCAL_QUERY_INTENT_CLASSIFIER = None
31
+ _QUERY_INTENT_CACHE: dict[str, dict] = {}
32
+ _QUERY_INTENT_LABELS = (
33
+ ("A how-to guide, procedure, or step-by-step instruction request", "howto"),
34
+ ("A definition, explanation of what something is, or conceptual clarification", "definition"),
35
+ ("A reasoning question asking why something happens or what caused it", "reasoning"),
36
+ ("A question about dates, sequence, chronology, or timeline", "temporal"),
37
+ ("A technical code, syntax, API, or implementation lookup", "technical"),
38
+ ("A general lookup or recall request for facts, status, or previous context", "lookup"),
39
+ )
40
+ _QUERY_INTENT_CUES = {
41
+ "howto": ("how to", "how do", "steps", "como", "cómo", "guide", "walkthrough"),
42
+ "definition": ("what is", "what are", "define", "explain", "que es", "qué es", "meaning of"),
43
+ "reasoning": ("why", "por que", "por qué", "reason", "cause", "because", "motivo"),
44
+ "temporal": ("when", "timeline", "date", "fecha", "cuando", "cuándo", "chronology"),
45
+ "technical": ("::", "def ", "class ", "fn ", "function ", "api", "endpoint", "stack trace"),
46
+ }
47
+
48
+
49
+ def _query_intent_cache_key(query: str) -> str:
50
+ return " ".join((query or "").lower().split())[:600]
51
+
52
+
53
+ def _semantic_query_intent_scores(query: str) -> dict[str, float]:
54
+ lowered = " " + " ".join((query or "").lower().split()) + " "
55
+ if len(lowered.strip()) < 8:
56
+ return {}
57
+ scores: dict[str, float] = {}
58
+ for intent, cues in _QUERY_INTENT_CUES.items():
59
+ hits = [cue for cue in cues if cue in lowered]
60
+ if not hits:
61
+ continue
62
+ score = 0.0
63
+ for cue in hits:
64
+ score += 0.28 if " " in cue else 0.18
65
+ scores[intent] = min(0.98, score)
66
+ return scores
67
+
68
+
69
+ def _local_classify_query_intent(query: str) -> dict:
70
+ key = _query_intent_cache_key(query)
71
+ if len(key) < 12:
72
+ return {"available": False, "label": None, "reason": "text_too_short"}
73
+
74
+ cached = _QUERY_INTENT_CACHE.get(key)
75
+ if cached and cached.get("expires_at", 0) > time.time():
76
+ return {k: v for k, v in cached.items() if k != "expires_at"}
77
+
78
+ global _LOCAL_QUERY_INTENT_CLASSIFIER
79
+ 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"}
88
+ label_texts = [label for label, _intent in _QUERY_INTENT_LABELS]
89
+ 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"}
93
+ intent = intent_by_label.get(result.label)
94
+ payload = {
95
+ "available": intent is not None,
96
+ "label": intent,
97
+ "confidence": float(result.confidence or 0.0),
98
+ "reason": "local_zero_shot",
99
+ }
100
+ _QUERY_INTENT_CACHE[key] = {
101
+ **payload,
102
+ "expires_at": time.time() + _QUERY_INTENT_CACHE_TTL_SECONDS,
103
+ }
104
+ return payload
105
+ except Exception as exc:
106
+ return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
107
+
20
108
  def bm25_search(query_text: str, stores: str = "both", top_k: int = 20,
21
109
  source_type_filter: str = "") -> list[dict]:
22
110
  """BM25 keyword search using SQLite FTS5. Returns ranked results by relevance."""
@@ -436,16 +524,27 @@ def _kg_boost_results(results: list[dict], max_boost: float = 0.08) -> list[dict
436
524
  # ============================================================================
437
525
 
438
526
  def _classify_query_intent(query: str) -> str:
439
- """Classify query intent into one of 6 categories (Vestige-style)."""
440
- lower = query.lower().strip()
441
- if lower.startswith(("how to", "how do", "steps", "cómo")):
442
- return "howto"
443
- if lower.startswith(("what is", "what are", "define", "explain", "qué es")):
444
- return "definition"
445
- if lower.startswith(("why", "por qué")) or "reason" in lower or "porque" in lower:
446
- return "reasoning"
447
- if lower.startswith(("when", "cuándo")) or "date" in lower or "timeline" in lower or "fecha" in lower:
448
- return "temporal"
527
+ """Classify query intent with semantic/local routing before lookup fallback."""
528
+ scores = _semantic_query_intent_scores(query)
529
+ if scores:
530
+ ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
531
+ winner, winner_score = ordered[0]
532
+ runner_up = ordered[1][1] if len(ordered) > 1 else 0.0
533
+ if winner_score >= _QUERY_INTENT_SCORE_THRESHOLD and (winner_score - runner_up) >= _QUERY_INTENT_SCORE_MARGIN:
534
+ return winner
535
+
536
+ local_result = _local_classify_query_intent(query)
537
+ if local_result.get("available"):
538
+ confidence = float(local_result.get("confidence", 0.0) or 0.0)
539
+ label = local_result.get("label")
540
+ if isinstance(label, str) and confidence >= _QUERY_INTENT_LOCAL_CONFIDENCE_THRESHOLD:
541
+ return label
542
+
543
+ if scores:
544
+ winner, winner_score = max(scores.items(), key=lambda item: item[1])
545
+ if winner_score >= 0.35:
546
+ return winner
547
+
449
548
  if any(c in query for c in ("(", "{", "::", "def ", "class ", "fn ", "function ")):
450
549
  return "technical"
451
550
  return "lookup"
@@ -1,4 +1,5 @@
1
1
  """NEXO Cognitive — Trust scoring, sentiment, dissonance."""
2
+ import os
2
3
  import re
3
4
  import numpy as np
4
5
  from datetime import datetime, timedelta, timezone
@@ -267,6 +268,72 @@ SENTIMENT_INTENTS = (
267
268
  "neutral",
268
269
  )
269
270
 
271
+ _LOCAL_SENTIMENT_CONFIDENCE_THRESHOLD = float(
272
+ os.environ.get("NEXO_SENTIMENT_LOCAL_CONFIDENCE", "0.68")
273
+ )
274
+ _LOCAL_SENTIMENT_CACHE_TTL_SECONDS = int(
275
+ os.environ.get("NEXO_SENTIMENT_LOCAL_CACHE_TTL", "21600")
276
+ )
277
+ _LOCAL_SENTIMENT_CLASSIFIER = None
278
+ _LOCAL_SENTIMENT_CACHE: dict[str, dict] = {}
279
+ _LOCAL_SENTIMENT_LABELS = (
280
+ ("The user is correcting the assistant or saying the assistant is wrong", "correction"),
281
+ ("The user is acknowledging or confirming that the assistant helped", "acknowledgement"),
282
+ ("The user is asking a question or requesting clarification", "question"),
283
+ ("The user is giving an instruction or a direct task to execute", "instruction"),
284
+ ("The user needs immediate action because the situation is urgent", "urgency"),
285
+ ("The user is complaining or expressing frustration", "complaint"),
286
+ ("The user is praising the assistant or celebrating the result", "praise"),
287
+ ("The message is neutral and mostly informational", "neutral"),
288
+ )
289
+
290
+
291
+ def _local_sentiment_cache_key(text: str) -> str:
292
+ return " ".join((text or "").lower().split())[:600]
293
+
294
+
295
+ def _local_classify_sentiment_intent(text: str) -> dict:
296
+ key = _local_sentiment_cache_key(text)
297
+ if len(key) < 12:
298
+ return {"available": False, "label": None, "reason": "text_too_short"}
299
+
300
+ import time
301
+
302
+ now = time.time()
303
+ cached = _LOCAL_SENTIMENT_CACHE.get(key)
304
+ if cached and cached.get("expires_at", 0) > now:
305
+ return {k: v for k, v in cached.items() if k != "expires_at"}
306
+
307
+ global _LOCAL_SENTIMENT_CLASSIFIER
308
+ 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"}
317
+ label_texts = [label for label, _intent in _LOCAL_SENTIMENT_LABELS]
318
+ 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"}
322
+ intent = intent_by_label.get(result.label)
323
+ payload = {
324
+ "available": intent in SENTIMENT_INTENTS,
325
+ "label": intent,
326
+ "confidence": float(result.confidence or 0.0),
327
+ "reason": "local_zero_shot",
328
+ }
329
+ _LOCAL_SENTIMENT_CACHE[key] = {
330
+ **payload,
331
+ "expires_at": now + _LOCAL_SENTIMENT_CACHE_TTL_SECONDS,
332
+ }
333
+ return payload
334
+ except Exception as exc:
335
+ return {"available": False, "label": None, "reason": f"classifier_error:{exc}"}
336
+
270
337
 
271
338
  def detect_sentiment(text: str) -> dict:
272
339
  """Analyze user's text for sentiment signals.
@@ -366,6 +433,29 @@ def detect_sentiment(text: str) -> dict:
366
433
  )
367
434
  )
368
435
 
436
+ semantic_override = False
437
+ local_intent = None
438
+ if (
439
+ len(text.strip()) >= 18
440
+ and (
441
+ not any([
442
+ correction_hits,
443
+ ack_hits,
444
+ instruction_hits,
445
+ question_hits,
446
+ urgency_hits,
447
+ positive_hits,
448
+ ])
449
+ or (sentiment == "negative" and not correction_hits and not question_hits)
450
+ or abs(pos_score - neg_score) <= 1
451
+ )
452
+ ):
453
+ local_result = _local_classify_sentiment_intent(text)
454
+ confidence = float(local_result.get("confidence", 0.0) or 0.0)
455
+ if local_result.get("available") and confidence >= _LOCAL_SENTIMENT_CONFIDENCE_THRESHOLD:
456
+ local_intent = local_result.get("label")
457
+ semantic_override = isinstance(local_intent, str)
458
+
369
459
  # Intent: prioritized enum — correction > question > instruction >
370
460
  # urgency > acknowledgement/praise > complaint > neutral.
371
461
  if is_correction:
@@ -385,6 +475,33 @@ def detect_sentiment(text: str) -> dict:
385
475
  else:
386
476
  intent = "neutral"
387
477
 
478
+ if semantic_override and local_intent in SENTIMENT_INTENTS:
479
+ intent = local_intent
480
+ if local_intent == "correction":
481
+ is_correction = True
482
+ sentiment = "negative"
483
+ intensity = max(float(intensity), 0.7)
484
+ guidance = "MODE: Ultra-concise. Zero explanations. Solve and show result."
485
+ valence = min(valence, -0.6)
486
+ elif local_intent == "urgency":
487
+ sentiment = "urgent"
488
+ intensity = max(float(intensity), 0.8)
489
+ guidance = "MODE: Immediate action. No preambles."
490
+ valence = min(valence, -0.2)
491
+ elif local_intent == "complaint":
492
+ sentiment = "negative"
493
+ intensity = max(float(intensity), 0.65)
494
+ guidance = "MODE: Concise. Less context, more direct action."
495
+ valence = min(valence, -0.45)
496
+ elif local_intent in {"acknowledgement", "praise"}:
497
+ sentiment = "positive"
498
+ intensity = max(float(intensity), 0.6)
499
+ if not guidance:
500
+ guidance = "MODE: Normal. Good time to suggest backlog ideas or improvements."
501
+ valence = max(valence, 0.45)
502
+ elif local_intent in {"question", "instruction"} and sentiment == "neutral":
503
+ intensity = max(float(intensity), 0.5)
504
+
388
505
  return {
389
506
  "sentiment": sentiment,
390
507
  "intensity": round(intensity, 2),