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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/bin/postinstall.js +2 -2
- package/package.json +1 -1
- package/src/agent_runner.py +4 -32
- package/src/auto_update.py +120 -1
- package/src/automation_controls.py +2 -1
- package/src/autonomy_mandate.py +17 -2
- package/src/bootstrap_docs.py +2 -1
- package/src/calibration_runtime.py +46 -0
- package/src/claude_cli.py +151 -0
- package/src/cli.py +114 -0
- package/src/client_preferences.py +84 -1
- package/src/client_sync.py +48 -2
- package/src/cognitive/_search.py +109 -10
- package/src/cognitive/_trust.py +117 -0
- package/src/core_schedule_controls.py +494 -0
- package/src/cron_recovery.py +11 -2
- package/src/crons/sync.py +10 -1
- package/src/db/__init__.py +1 -0
- package/src/db/_learnings.py +21 -9
- package/src/db/_protocol.py +33 -2
- package/src/db/_reminders.py +23 -13
- package/src/db/_schema.py +4 -0
- package/src/db/_semantic_similarity.py +98 -0
- package/src/db/_skills.py +28 -18
- package/src/doctor/providers/runtime.py +54 -9
- package/src/email_config.py +5 -0
- package/src/enforcement_engine.py +151 -2
- package/src/guard_verbal_ack.py +66 -0
- package/src/hook_guardrails.py +240 -27
- package/src/hooks/auto_capture.py +32 -7
- package/src/hooks/post_tool_use.py +8 -5
- package/src/paths.py +9 -0
- package/src/plugin_loader.py +65 -27
- package/src/plugins/core_rules.py +30 -1
- package/src/plugins/cortex.py +4 -1
- package/src/plugins/doctor.py +15 -0
- package/src/plugins/episodic_memory.py +52 -2
- package/src/plugins/evolution.py +22 -0
- package/src/plugins/guard.py +282 -26
- package/src/plugins/personal_scripts.py +116 -15
- package/src/plugins/protocol.py +358 -55
- package/src/plugins/skills.py +11 -2
- package/src/product_mode.py +28 -6
- package/src/scripts/check-context.py +3 -2
- package/src/scripts/nexo-catchup.py +6 -25
- package/src/scripts/nexo-daily-self-audit.py +0 -21
- package/src/scripts/nexo-email-monitor.py +51 -10
- package/src/scripts/nexo-evolution-run.py +5 -22
- package/src/scripts/nexo-postmortem-consolidator.py +0 -21
- package/src/scripts/nexo-send-reply.py +40 -0
- package/src/scripts/nexo-sleep.py +0 -20
- package/src/scripts/nexo-synthesis.py +0 -21
- package/src/scripts/nexo-watchdog.sh +28 -30
- package/src/server.py +4 -86
- package/src/session_end_intent.py +31 -0
- package/src/skills/create-nexo-primitive/guide.md +87 -0
- package/src/skills/create-nexo-primitive/skill.json +62 -0
- package/src/tools_drive.py +277 -12
- package/src/tools_email_guard.py +74 -0
- package/src/tools_hot_context.py +11 -2
- package/src/tools_sessions.py +61 -9
- package/src/tools_system_catalog.py +2 -2
- package/src/user_context.py +2 -1
- package/templates/CLAUDE.md.template +1 -1
- package/templates/CODEX.AGENTS.md.template +1 -1
- package/templates/core-prompts/automation-backend-probe.md +1 -0
- package/templates/core-prompts/autonomy-mandate-question.md +6 -0
- package/templates/core-prompts/codex-protocol-contract.md +7 -0
- package/templates/core-prompts/drive-area-classifier-system.md +4 -0
- package/templates/core-prompts/drive-area-classifier-user.md +6 -0
- package/templates/core-prompts/email-monitor.md +2 -0
- package/templates/core-prompts/guard-verbal-ack-question.md +1 -0
- package/templates/core-prompts/heartbeat-diary-overdue.md +1 -0
- package/templates/core-prompts/heartbeat-guard-reminder.md +1 -0
- package/templates/core-prompts/heartbeat-learning-reminder.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-guard-required.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-heartbeat-close-evidence.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-startup-required.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-task-open-guard-note.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-task-open-required.md +1 -0
- package/templates/core-prompts/hook-protocol-warning-workflow-required.md +1 -0
- package/templates/core-prompts/post-tool-inbox-reminder.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +38 -0
- package/templates/core-prompts/session-end-intent-question.md +1 -0
- package/templates/core-prompts/watchdog-repair.md +25 -0
- package/templates/launchagents/README.md +1 -1
- 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
|
-
|
|
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":
|
package/src/client_sync.py
CHANGED
|
@@ -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 =
|
|
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"],
|
package/src/cognitive/_search.py
CHANGED
|
@@ -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
|
|
440
|
-
|
|
441
|
-
if
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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"
|
package/src/cognitive/_trust.py
CHANGED
|
@@ -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),
|