nexo-brain 6.0.5 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/nexo-brain.js +33 -0
- package/package.json +2 -2
- package/src/auto_update.py +31 -0
- package/src/call_model_raw.py +342 -0
- package/src/cli.py +30 -0
- package/src/client_preferences.py +31 -0
- package/src/cognitive/_memory.py +47 -2
- package/src/db/_schema.py +42 -0
- package/src/desktop_bridge.py +58 -0
- package/src/enforcement_classifier.py +198 -0
- package/src/enforcement_engine.py +1497 -27
- package/src/guardian_config.py +284 -0
- package/src/guardian_telemetry.py +134 -0
- package/src/hook_guardrails.py +99 -9
- package/src/plugins/artifact_registry.py +118 -0
- package/src/plugins/protocol.py +68 -4
- package/src/plugins/simple_api.py +97 -3
- package/src/plugins/workflow.py +58 -0
- package/src/presets/entities_universal.json +235 -0
- package/src/presets/guardian_default.json +87 -0
- package/src/r13_pre_edit_guard.py +146 -0
- package/src/r14_correction_learning.py +95 -0
- package/src/r15_project_context.py +134 -0
- package/src/r16_declared_done.py +72 -0
- package/src/r17_promise_debt.py +67 -0
- package/src/r18_followup_autocomplete.py +118 -0
- package/src/r19_project_grep.py +103 -0
- package/src/r20_constant_change.py +147 -0
- package/src/r21_legacy_path.py +86 -0
- package/src/r22_personal_script.py +112 -0
- package/src/r23_ssh_without_atlas.py +103 -0
- package/src/r23b_deploy_vhost.py +113 -0
- package/src/r23c_cwd_mismatch.py +83 -0
- package/src/r23d_chown_chmod_recursive.py +96 -0
- package/src/r23e_force_push_main.py +92 -0
- package/src/r23f_db_no_where.py +112 -0
- package/src/r23g_secrets_in_output.py +72 -0
- package/src/r23h_shebang_mismatch.py +115 -0
- package/src/r23i_auto_deploy_ignored.py +65 -0
- package/src/r23j_global_install.py +104 -0
- package/src/r23k_script_duplicates_skill.py +55 -0
- package/src/r23l_resource_collision.py +112 -0
- package/src/r23m_message_duplicate.py +73 -0
- package/src/r24_stale_memory.py +75 -0
- package/src/r25_nora_maria_read_only.py +197 -0
- package/src/requirements.txt +8 -0
- package/src/resonance_map.py +8 -0
- package/src/resonance_tiers.json +4 -0
- package/src/server.py +28 -1
- package/src/tools_credentials.py +43 -3
- package/src/tools_learnings.py +43 -1
- package/src/tools_reminders_crud.py +168 -0
- package/src/tools_sessions.py +55 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `6.0
|
|
21
|
+
Version `6.1.0` is the current packaged-runtime line: the Protocol Enforcer Fase 2 ships — a Capa 2 runtime guardian with 25 rules (R13–R25 + R23b–R23m) that keep Claude Code / Codex / Desktop aligned to NEXO protocol. Rule coverage: pre-Edit guard (R13), post-correction learning window (R14), declared-done without close (R16), Nora/María read-only destructive block (R25), plus 21 more across stream wrapper layers (Fase C + D + D2). Also lands migration v43 `session_claude_aliases` — a 1-to-N map from NEXO sid to every Claude session UUID, fixing the NEXO Desktop multi-conversation block where every second conversation's PreToolUse hook failed with "unknown target". External-LLM audit + Opus 4.7 self-audit cycle applied (log redaction with modern token formats, R23f heredoc multiline, R23h native PATH resolution, R14 awaited, hermetic map lookup, cross-engine parity harness strict). Suite: 291 pass + 2 skip documented.
|
|
22
22
|
|
|
23
23
|
Previously in `6.0.2`: adds the reserved caller prefix `personal/*` so scripts living in `~/.nexo/scripts/` can invoke the automation backend with their own caller id without editing `src/resonance_map.py`. New kwarg `tier` (`"maximo"` / `"alto"` / `"medio"` / `"bajo"`) on `run_automation_prompt`, `run_automation_interactive`, `nexo_helper.run_automation_text`, `nexo_helper.run_automation_json`, and `nexo-agent-run.py --tier`. Precedence for `personal/*` callers: explicit `tier=` → explicit `reasoning_effort=` → `calibration.preferences.default_resonance` → `DEFAULT_RESONANCE` (`alto`). Registered callers keep their behaviour unchanged. New guide: [`docs/personal-scripts-guide.md`](docs/personal-scripts-guide.md).
|
|
24
24
|
|
package/bin/nexo-brain.js
CHANGED
|
@@ -21,6 +21,27 @@ const path = require("path");
|
|
|
21
21
|
const readline = require("readline");
|
|
22
22
|
|
|
23
23
|
let NEXO_HOME = process.env.NEXO_HOME || path.join(require("os").homedir(), ".nexo");
|
|
24
|
+
|
|
25
|
+
function shouldSkipShellProfileBackfill() {
|
|
26
|
+
// Mirror of _should_skip_shell_profile_backfill() in src/auto_update.py.
|
|
27
|
+
// Prevent the installer from leaking ``export PATH`` / operator alias lines
|
|
28
|
+
// into the developer's real shell profile whenever NEXO_HOME is not the
|
|
29
|
+
// canonical $HOME/.nexo path (pytest tmp dirs, CI sandboxes, containers).
|
|
30
|
+
const flag = String(process.env.NEXO_SKIP_SHELL_PROFILE || "").trim().toLowerCase();
|
|
31
|
+
if (["1", "true", "yes", "on"].includes(flag)) {
|
|
32
|
+
return { skip: true, reason: `NEXO_SKIP_SHELL_PROFILE=${flag}` };
|
|
33
|
+
}
|
|
34
|
+
const canonical = path.join(require("os").homedir(), ".nexo");
|
|
35
|
+
let actual = NEXO_HOME;
|
|
36
|
+
try {
|
|
37
|
+
actual = path.resolve(NEXO_HOME);
|
|
38
|
+
} catch {}
|
|
39
|
+
if (actual !== canonical) {
|
|
40
|
+
return { skip: true, reason: `NEXO_HOME=${actual} is not the canonical ${canonical}` };
|
|
41
|
+
}
|
|
42
|
+
return { skip: false, reason: "" };
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
const CLAUDE_SETTINGS = path.join(
|
|
25
46
|
require("os").homedir(),
|
|
26
47
|
".claude",
|
|
@@ -1856,6 +1877,10 @@ async function main() {
|
|
|
1856
1877
|
const migOperatorName = installed.operator_name || "NEXO";
|
|
1857
1878
|
const migAliasName = migOperatorName.toLowerCase();
|
|
1858
1879
|
if (migAliasName !== "nexo") {
|
|
1880
|
+
const migSkip = shouldSkipShellProfileBackfill();
|
|
1881
|
+
if (migSkip.skip) {
|
|
1882
|
+
log(` Skipping shell profile alias restore — ${migSkip.reason}`);
|
|
1883
|
+
} else {
|
|
1859
1884
|
const migAliasLine = `alias ${migAliasName}='nexo chat .'`;
|
|
1860
1885
|
const migAliasComment = `# ${migOperatorName} — open the configured NEXO terminal client`;
|
|
1861
1886
|
const migNexoPathLine = `export PATH="${path.join(NEXO_HOME, "bin")}:$PATH"`;
|
|
@@ -1884,6 +1909,7 @@ async function main() {
|
|
|
1884
1909
|
log(` Restored '${migAliasName}' alias in ${path.basename(rcFile)}`);
|
|
1885
1910
|
}
|
|
1886
1911
|
}
|
|
1912
|
+
}
|
|
1887
1913
|
}
|
|
1888
1914
|
|
|
1889
1915
|
console.log("");
|
|
@@ -3390,6 +3416,12 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
|
|
|
3390
3416
|
|
|
3391
3417
|
// Step 8: Create shell alias and add runtime CLI to PATH
|
|
3392
3418
|
const aliasName = operatorName.toLowerCase();
|
|
3419
|
+
const installSkipShell = shouldSkipShellProfileBackfill();
|
|
3420
|
+
if (installSkipShell.skip) {
|
|
3421
|
+
log(`Skipping shell profile setup — ${installSkipShell.reason}`);
|
|
3422
|
+
log(`(Runtime CLI wrapper still installed at ${path.join(NEXO_HOME, "bin", "nexo")}; add it to PATH manually if needed.)`);
|
|
3423
|
+
console.log("");
|
|
3424
|
+
} else {
|
|
3393
3425
|
const nexoPathLine = `export PATH="${path.join(NEXO_HOME, "bin")}:$PATH"`;
|
|
3394
3426
|
const nexoPathComment = "# NEXO runtime CLI";
|
|
3395
3427
|
|
|
@@ -3442,6 +3474,7 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
|
|
|
3442
3474
|
log(`After setup, open a new terminal and type: ${aliasName} or nexo`);
|
|
3443
3475
|
}
|
|
3444
3476
|
console.log("");
|
|
3477
|
+
}
|
|
3445
3478
|
|
|
3446
3479
|
// Step 9: Generate CLAUDE.md template
|
|
3447
3480
|
log("Generating operator instructions...");
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
5
|
+
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
7
7
|
"bin": {
|
|
8
8
|
"nexo-brain": "./bin/nexo-brain.js",
|
package/src/auto_update.py
CHANGED
|
@@ -383,7 +383,38 @@ def _shell_rc_files() -> list[Path]:
|
|
|
383
383
|
return [home_dir / ".bash_profile", home_dir / ".bashrc"]
|
|
384
384
|
|
|
385
385
|
|
|
386
|
+
def _should_skip_shell_profile_backfill() -> tuple[bool, str]:
|
|
387
|
+
"""Decide whether to skip writing ``export PATH`` into the user's shell rc.
|
|
388
|
+
|
|
389
|
+
The backfill must only touch the real user shell profile when the runtime
|
|
390
|
+
is installed at the canonical ``$HOME/.nexo`` location. When ``NEXO_HOME``
|
|
391
|
+
points elsewhere (pytest ``tmp_path``, CI sandbox, containerised test
|
|
392
|
+
harness) writing to ``~/.bash_profile`` / ``~/.bashrc`` / ``~/.zshrc``
|
|
393
|
+
leaks a non-canonical ``export PATH`` line into the developer's real
|
|
394
|
+
shell, which is exactly the bug reported for v6.0.x. Users can force-skip
|
|
395
|
+
via ``NEXO_SKIP_SHELL_PROFILE=1``.
|
|
396
|
+
"""
|
|
397
|
+
flag = os.environ.get("NEXO_SKIP_SHELL_PROFILE", "").strip().lower()
|
|
398
|
+
if flag in {"1", "true", "yes", "on"}:
|
|
399
|
+
return True, f"NEXO_SKIP_SHELL_PROFILE={flag}"
|
|
400
|
+
try:
|
|
401
|
+
canonical = managed_nexo_home()
|
|
402
|
+
except Exception:
|
|
403
|
+
canonical = Path.home() / ".nexo"
|
|
404
|
+
try:
|
|
405
|
+
same = NEXO_HOME.resolve(strict=False) == canonical.resolve(strict=False)
|
|
406
|
+
except Exception:
|
|
407
|
+
same = NEXO_HOME == canonical
|
|
408
|
+
if not same:
|
|
409
|
+
return True, f"NEXO_HOME={NEXO_HOME} is not the canonical {canonical}"
|
|
410
|
+
return False, ""
|
|
411
|
+
|
|
412
|
+
|
|
386
413
|
def _ensure_runtime_cli_in_shell():
|
|
414
|
+
skip, reason = _should_skip_shell_profile_backfill()
|
|
415
|
+
if skip:
|
|
416
|
+
_log(f"Skipping shell profile backfill — {reason}")
|
|
417
|
+
return
|
|
387
418
|
path_line = f'export PATH="{NEXO_HOME / "bin"}:$PATH"'
|
|
388
419
|
comment = "# NEXO runtime CLI"
|
|
389
420
|
for rc_file in _shell_rc_files():
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""call_model_raw — Plain LLM invocation for the Protocol Enforcer classifier.
|
|
2
|
+
|
|
3
|
+
Fase 2 spec item 0.1 + 0.20. Provides a DIRECT SDK call that bypasses the
|
|
4
|
+
Claude Code CLI, the NEXO MCP server and the enforcement wrapper. Designed
|
|
5
|
+
for short yes/no classification (R13 pre-edit, R14 correction, R16
|
|
6
|
+
declared-done, R17 promise, R20 constant-change, etc.) where starting the
|
|
7
|
+
full automation stack would dwarf the actual cost of the model call.
|
|
8
|
+
|
|
9
|
+
Design contract (from plan doc 1 "Refactor de keywords/regex hardcoded —
|
|
10
|
+
Mecanismo C"):
|
|
11
|
+
|
|
12
|
+
- Resolve (model, effort) via resonance_map.resolve_model_and_effort on
|
|
13
|
+
caller "enforcer_classifier" (tier "muy_bajo"). Respects user's backend
|
|
14
|
+
preference via resolve_automation_backend.
|
|
15
|
+
- Direct SDK call to the resolved backend (anthropic or openai).
|
|
16
|
+
- Triple reinforcement for yes/no parsing is implemented in the caller
|
|
17
|
+
(enforcement_classifier.py): system prompt strict + max_tokens<=3 +
|
|
18
|
+
regex parser with one retry.
|
|
19
|
+
- Fail-closed: every transient error (timeout, rate limit, 5xx,
|
|
20
|
+
connection) raises ClassifierUnavailableError. Upstream catches and
|
|
21
|
+
degrades the rule to shadow or injects a generic reminder. Never
|
|
22
|
+
fail-open. Rule #249, #294.
|
|
23
|
+
- No MCP tools, no hook side-effects, no subprocess. This function is
|
|
24
|
+
safe to call inside enforcement hot paths.
|
|
25
|
+
|
|
26
|
+
This module deliberately does NOT live inside agent_runner.py so that:
|
|
27
|
+
|
|
28
|
+
1. agent_runner.py stays focused on automation subprocess orchestration.
|
|
29
|
+
2. enforcement_engine.py (headless) and tools that run outside an
|
|
30
|
+
automation subprocess can import call_model_raw without pulling in
|
|
31
|
+
the rest of agent_runner.py.
|
|
32
|
+
3. Tests for call_model_raw (test_call_model_raw.py) can mock the SDK
|
|
33
|
+
entry points precisely without monkey-patching agent_runner.
|
|
34
|
+
|
|
35
|
+
Historical note: pre-Fase 2, callers sometimes reached for
|
|
36
|
+
run_automation_prompt() when they needed a one-shot model call. That
|
|
37
|
+
starts a full Claude Code session and a full NEXO MCP handshake — a
|
|
38
|
+
disaster for per-turn classification cost. call_model_raw closes that
|
|
39
|
+
gap.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClassifierUnavailableError(RuntimeError):
|
|
49
|
+
"""Signal that the enforcer classifier backend is unavailable.
|
|
50
|
+
|
|
51
|
+
Fase 2 spec 0.20: callers MUST catch this and fall back to a safer
|
|
52
|
+
default (inject generic reminder, degrade rule to shadow for the
|
|
53
|
+
session, etc.). Never fail-open. Learning #249: structured protocol
|
|
54
|
+
inputs must fail explicitly, never coerce silently.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_ANTHROPIC_KEY_PATHS = (
|
|
59
|
+
Path.home() / ".claude" / "anthropic-api-key.txt",
|
|
60
|
+
Path.home() / ".nexo" / "config" / "anthropic-api-key.txt",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_OPENAI_KEY_PATHS = (
|
|
64
|
+
Path.home() / ".nexo" / "config" / "openai-api-key.txt",
|
|
65
|
+
Path.home() / ".codex" / "auth.json",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_anthropic_key() -> str:
|
|
70
|
+
env_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
71
|
+
if env_key:
|
|
72
|
+
return env_key
|
|
73
|
+
for path in _ANTHROPIC_KEY_PATHS:
|
|
74
|
+
try:
|
|
75
|
+
if path.is_file():
|
|
76
|
+
key = path.read_text().strip()
|
|
77
|
+
if key:
|
|
78
|
+
return key
|
|
79
|
+
except OSError:
|
|
80
|
+
continue
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_openai_key() -> str:
|
|
85
|
+
env_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
|
86
|
+
if env_key:
|
|
87
|
+
return env_key
|
|
88
|
+
for path in _OPENAI_KEY_PATHS:
|
|
89
|
+
try:
|
|
90
|
+
if not path.is_file():
|
|
91
|
+
continue
|
|
92
|
+
text = path.read_text().strip()
|
|
93
|
+
if not text:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(text)
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
for candidate in ("OPENAI_API_KEY", "api_key", "openai_api_key"):
|
|
99
|
+
value = str(data.get(candidate, "") or "").strip()
|
|
100
|
+
if value:
|
|
101
|
+
return value
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
return text
|
|
104
|
+
except OSError:
|
|
105
|
+
continue
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_anthropic_text(response) -> str:
|
|
110
|
+
try:
|
|
111
|
+
blocks = list(getattr(response, "content", None) or [])
|
|
112
|
+
except Exception as _exc: # noqa: BLE001
|
|
113
|
+
# Audit-MEDIUM: log SDK drift so operators see when the Anthropic
|
|
114
|
+
# response shape changes between minor versions.
|
|
115
|
+
import logging as _log
|
|
116
|
+
_log.getLogger("nexo.enforcer").warning(
|
|
117
|
+
"anthropic extract_text failed (%s); returning empty", _exc
|
|
118
|
+
)
|
|
119
|
+
return ""
|
|
120
|
+
for block in blocks:
|
|
121
|
+
text = getattr(block, "text", None)
|
|
122
|
+
if text:
|
|
123
|
+
return str(text).strip()
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_openai_text(response) -> str:
|
|
128
|
+
try:
|
|
129
|
+
choices = getattr(response, "choices", None) or []
|
|
130
|
+
if not choices:
|
|
131
|
+
return ""
|
|
132
|
+
message = getattr(choices[0], "message", None)
|
|
133
|
+
content = getattr(message, "content", None)
|
|
134
|
+
if content is None and isinstance(message, dict):
|
|
135
|
+
content = message.get("content")
|
|
136
|
+
return str(content or "").strip()
|
|
137
|
+
except Exception:
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _call_anthropic_raw(
|
|
142
|
+
*,
|
|
143
|
+
prompt: str,
|
|
144
|
+
system: str | None,
|
|
145
|
+
model: str,
|
|
146
|
+
max_tokens: int,
|
|
147
|
+
temperature: float,
|
|
148
|
+
stop_sequences: list[str],
|
|
149
|
+
timeout: float,
|
|
150
|
+
) -> str:
|
|
151
|
+
try:
|
|
152
|
+
import anthropic # type: ignore
|
|
153
|
+
except ImportError as exc:
|
|
154
|
+
raise ClassifierUnavailableError(f"anthropic SDK missing: {exc}") from exc
|
|
155
|
+
|
|
156
|
+
api_key = _resolve_anthropic_key()
|
|
157
|
+
if not api_key:
|
|
158
|
+
raise ClassifierUnavailableError("anthropic: no ANTHROPIC_API_KEY found")
|
|
159
|
+
|
|
160
|
+
client = anthropic.Anthropic(api_key=api_key, timeout=timeout)
|
|
161
|
+
kwargs: dict = {
|
|
162
|
+
"model": model,
|
|
163
|
+
"max_tokens": max_tokens,
|
|
164
|
+
"temperature": temperature,
|
|
165
|
+
"stop_sequences": stop_sequences,
|
|
166
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
167
|
+
}
|
|
168
|
+
if system:
|
|
169
|
+
kwargs["system"] = system
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
response = client.messages.create(**kwargs)
|
|
173
|
+
except anthropic.APITimeoutError as exc:
|
|
174
|
+
raise ClassifierUnavailableError(f"anthropic timeout: {exc}") from exc
|
|
175
|
+
except anthropic.RateLimitError as exc:
|
|
176
|
+
raise ClassifierUnavailableError(f"anthropic rate_limit: {exc}") from exc
|
|
177
|
+
except anthropic.APIConnectionError as exc:
|
|
178
|
+
raise ClassifierUnavailableError(f"anthropic connection: {exc}") from exc
|
|
179
|
+
except anthropic.APIStatusError as exc:
|
|
180
|
+
status = getattr(exc, "status_code", 0)
|
|
181
|
+
if 500 <= status < 600:
|
|
182
|
+
raise ClassifierUnavailableError(f"anthropic 5xx: {status} {exc}") from exc
|
|
183
|
+
raise ClassifierUnavailableError(f"anthropic {status}: {exc}") from exc
|
|
184
|
+
except Exception as exc: # noqa: BLE001 — fail-closed wrapper
|
|
185
|
+
raise ClassifierUnavailableError(f"anthropic unexpected: {exc}") from exc
|
|
186
|
+
|
|
187
|
+
return _extract_anthropic_text(response)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _call_openai_raw(
|
|
191
|
+
*,
|
|
192
|
+
prompt: str,
|
|
193
|
+
system: str | None,
|
|
194
|
+
model: str,
|
|
195
|
+
max_tokens: int,
|
|
196
|
+
temperature: float,
|
|
197
|
+
stop_sequences: list[str],
|
|
198
|
+
timeout: float,
|
|
199
|
+
) -> str:
|
|
200
|
+
try:
|
|
201
|
+
import openai # type: ignore
|
|
202
|
+
except ImportError as exc:
|
|
203
|
+
raise ClassifierUnavailableError(f"openai SDK missing: {exc}") from exc
|
|
204
|
+
|
|
205
|
+
api_key = _resolve_openai_key()
|
|
206
|
+
if not api_key:
|
|
207
|
+
raise ClassifierUnavailableError("openai: no OPENAI_API_KEY found")
|
|
208
|
+
|
|
209
|
+
client = openai.OpenAI(api_key=api_key, timeout=timeout)
|
|
210
|
+
messages: list[dict] = []
|
|
211
|
+
if system:
|
|
212
|
+
messages.append({"role": "system", "content": system})
|
|
213
|
+
messages.append({"role": "user", "content": prompt})
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
response = client.chat.completions.create(
|
|
217
|
+
model=model,
|
|
218
|
+
messages=messages,
|
|
219
|
+
max_tokens=max_tokens,
|
|
220
|
+
temperature=temperature,
|
|
221
|
+
stop=stop_sequences,
|
|
222
|
+
)
|
|
223
|
+
except openai.APITimeoutError as exc:
|
|
224
|
+
raise ClassifierUnavailableError(f"openai timeout: {exc}") from exc
|
|
225
|
+
except openai.RateLimitError as exc:
|
|
226
|
+
raise ClassifierUnavailableError(f"openai rate_limit: {exc}") from exc
|
|
227
|
+
except openai.APIConnectionError as exc:
|
|
228
|
+
raise ClassifierUnavailableError(f"openai connection: {exc}") from exc
|
|
229
|
+
except openai.APIStatusError as exc:
|
|
230
|
+
status = getattr(exc, "status_code", 0)
|
|
231
|
+
if 500 <= status < 600:
|
|
232
|
+
raise ClassifierUnavailableError(f"openai 5xx: {status} {exc}") from exc
|
|
233
|
+
raise ClassifierUnavailableError(f"openai {status}: {exc}") from exc
|
|
234
|
+
except Exception as exc: # noqa: BLE001 — fail-closed wrapper
|
|
235
|
+
raise ClassifierUnavailableError(f"openai unexpected: {exc}") from exc
|
|
236
|
+
|
|
237
|
+
return _extract_openai_text(response)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def call_model_raw(
|
|
241
|
+
prompt: str,
|
|
242
|
+
*,
|
|
243
|
+
tier: str = "muy_bajo",
|
|
244
|
+
caller: str = "enforcer_classifier",
|
|
245
|
+
max_tokens: int = 3,
|
|
246
|
+
temperature: float = 0.0,
|
|
247
|
+
stop_sequences: list[str] | None = None,
|
|
248
|
+
timeout: float = 10.0,
|
|
249
|
+
system: str | None = None,
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Run a single short LLM completion for enforcement-class classification.
|
|
252
|
+
|
|
253
|
+
Parameters follow the Fase 2 plan doc 1 spec:
|
|
254
|
+
|
|
255
|
+
prompt — the user-role text (English or the model's default).
|
|
256
|
+
tier — resonance tier; default "muy_bajo" → Haiku / gpt-5.4-mini.
|
|
257
|
+
caller — resonance caller label. Must be registered in
|
|
258
|
+
resonance_map.SYSTEM_OWNED_CALLERS. Default
|
|
259
|
+
"enforcer_classifier".
|
|
260
|
+
max_tokens — hard cap on output tokens. Default 3 (yes/no only).
|
|
261
|
+
temperature — sampling temperature. Default 0.0 (deterministic).
|
|
262
|
+
stop_sequences — early-stop strings. Default ["\\n", ".", " "].
|
|
263
|
+
timeout — per-request timeout in seconds. Default 10.0.
|
|
264
|
+
system — optional system prompt. Default None (provider default).
|
|
265
|
+
|
|
266
|
+
Returns the raw text response, trimmed. The CALLER is responsible for
|
|
267
|
+
parsing yes/no — the "triple reinforcement" (prompt strict, max_tokens
|
|
268
|
+
tiny, regex parser with retry, fallback conservative) is implemented in
|
|
269
|
+
enforcement_classifier.py on top of this function.
|
|
270
|
+
|
|
271
|
+
Raises ClassifierUnavailableError on any of:
|
|
272
|
+
|
|
273
|
+
- automation_backend == none (user disabled automation)
|
|
274
|
+
- tier not present in resonance_tiers.json for the resolved backend
|
|
275
|
+
- SDK package missing
|
|
276
|
+
- API key missing
|
|
277
|
+
- Timeout / rate limit / 5xx / ConnectionError / any unexpected exception
|
|
278
|
+
|
|
279
|
+
Callers MUST catch this and fall back to a safer default. Fase 2 spec
|
|
280
|
+
0.20 is explicit: silence is not obedience. Never fail-open.
|
|
281
|
+
"""
|
|
282
|
+
if stop_sequences is None:
|
|
283
|
+
stop_sequences = ["\n", ".", " "]
|
|
284
|
+
|
|
285
|
+
# Local imports to avoid circulars and keep agent_runner.py decoupled.
|
|
286
|
+
from client_preferences import ( # type: ignore
|
|
287
|
+
BACKEND_NONE,
|
|
288
|
+
CLIENT_CLAUDE_CODE,
|
|
289
|
+
CLIENT_CODEX,
|
|
290
|
+
load_client_preferences,
|
|
291
|
+
resolve_automation_backend,
|
|
292
|
+
)
|
|
293
|
+
from resonance_map import ( # type: ignore
|
|
294
|
+
UnregisteredCallerError,
|
|
295
|
+
resolve_model_and_effort,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
prefs = load_client_preferences()
|
|
299
|
+
backend = resolve_automation_backend(preferences=prefs)
|
|
300
|
+
if backend == BACKEND_NONE:
|
|
301
|
+
raise ClassifierUnavailableError("automation_backend=none")
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
model, _effort = resolve_model_and_effort(
|
|
305
|
+
caller=caller,
|
|
306
|
+
backend=backend,
|
|
307
|
+
explicit_tier=tier,
|
|
308
|
+
)
|
|
309
|
+
except UnregisteredCallerError as exc:
|
|
310
|
+
raise ClassifierUnavailableError(f"caller not registered: {exc}") from exc
|
|
311
|
+
|
|
312
|
+
if not model:
|
|
313
|
+
raise ClassifierUnavailableError(
|
|
314
|
+
f"no (model, effort) for tier={tier!r} backend={backend!r}; "
|
|
315
|
+
f"check resonance_tiers.json"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if backend == CLIENT_CLAUDE_CODE:
|
|
319
|
+
return _call_anthropic_raw(
|
|
320
|
+
prompt=prompt,
|
|
321
|
+
system=system,
|
|
322
|
+
model=model,
|
|
323
|
+
max_tokens=max_tokens,
|
|
324
|
+
temperature=temperature,
|
|
325
|
+
stop_sequences=stop_sequences,
|
|
326
|
+
timeout=timeout,
|
|
327
|
+
)
|
|
328
|
+
if backend == CLIENT_CODEX:
|
|
329
|
+
return _call_openai_raw(
|
|
330
|
+
prompt=prompt,
|
|
331
|
+
system=system,
|
|
332
|
+
model=model,
|
|
333
|
+
max_tokens=max_tokens,
|
|
334
|
+
temperature=temperature,
|
|
335
|
+
stop_sequences=stop_sequences,
|
|
336
|
+
timeout=timeout,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
raise ClassifierUnavailableError(f"unsupported backend: {backend}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
__all__ = ["call_model_raw", "ClassifierUnavailableError"]
|
package/src/cli.py
CHANGED
|
@@ -2277,6 +2277,25 @@ def main():
|
|
|
2277
2277
|
dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
|
|
2278
2278
|
|
|
2279
2279
|
# -- desktop bridge (read-only, for NEXO Desktop and any external UI) --
|
|
2280
|
+
# Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
|
|
2281
|
+
quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
|
|
2282
|
+
quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
|
|
2283
|
+
|
|
2284
|
+
qlist_p = quarantine_sub.add_parser("list", help="List quarantine items")
|
|
2285
|
+
qlist_p.add_argument("--status", default="pending",
|
|
2286
|
+
choices=["pending", "promoted", "rejected", "expired", "all"])
|
|
2287
|
+
qlist_p.add_argument("--limit", type=int, default=20)
|
|
2288
|
+
qlist_p.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2289
|
+
|
|
2290
|
+
qpromote_p = quarantine_sub.add_parser("promote", help="Promote a quarantine item to STM")
|
|
2291
|
+
qpromote_p.add_argument("id", help="Quarantine item id")
|
|
2292
|
+
qpromote_p.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2293
|
+
|
|
2294
|
+
qreject_p = quarantine_sub.add_parser("reject", help="Reject a quarantine item")
|
|
2295
|
+
qreject_p.add_argument("id", help="Quarantine item id")
|
|
2296
|
+
qreject_p.add_argument("--reason", default="", help="Optional rejection reason")
|
|
2297
|
+
qreject_p.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2298
|
+
|
|
2280
2299
|
schema_parser = sub.add_parser("schema", help="Editable-field schema for Preferences UI")
|
|
2281
2300
|
schema_parser.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2282
2301
|
|
|
@@ -2410,6 +2429,17 @@ def main():
|
|
|
2410
2429
|
return _uninstall(args)
|
|
2411
2430
|
elif args.command == "dashboard":
|
|
2412
2431
|
return _dashboard(args)
|
|
2432
|
+
elif args.command == "quarantine":
|
|
2433
|
+
from desktop_bridge import cmd_quarantine_list, cmd_quarantine_promote, cmd_quarantine_reject
|
|
2434
|
+
if args.quarantine_command == "list":
|
|
2435
|
+
return cmd_quarantine_list(args)
|
|
2436
|
+
if args.quarantine_command == "promote":
|
|
2437
|
+
return cmd_quarantine_promote(args)
|
|
2438
|
+
if args.quarantine_command == "reject":
|
|
2439
|
+
return cmd_quarantine_reject(args)
|
|
2440
|
+
# No subcommand — show help.
|
|
2441
|
+
quarantine_parser.print_help()
|
|
2442
|
+
return 1
|
|
2413
2443
|
elif args.command in ("schema", "identity", "onboard", "scan-profile"):
|
|
2414
2444
|
from desktop_bridge import cmd_schema, cmd_identity, cmd_onboard, cmd_scan_profile
|
|
2415
2445
|
return {
|
|
@@ -98,6 +98,10 @@ def default_client_preferences() -> dict:
|
|
|
98
98
|
"last_terminal_client": "",
|
|
99
99
|
"automation_enabled": True,
|
|
100
100
|
"automation_backend": CLIENT_CLAUDE_CODE,
|
|
101
|
+
# True iff the user has EXPLICITLY changed automation_backend from its
|
|
102
|
+
# installer-chosen value. Installer/update flows (Fase E) only rewrite
|
|
103
|
+
# automation_backend if this flag is False — respects user opt-out.
|
|
104
|
+
"automation_user_override": False,
|
|
101
105
|
"client_runtime_profiles": default_client_runtime_profiles(),
|
|
102
106
|
"automation_task_profiles": default_automation_task_profiles(),
|
|
103
107
|
"client_install_preferences": {
|
|
@@ -233,6 +237,21 @@ def normalize_automation_enabled(value) -> bool:
|
|
|
233
237
|
return _coerce_bool(value, True)
|
|
234
238
|
|
|
235
239
|
|
|
240
|
+
def normalize_automation_user_override(value) -> bool:
|
|
241
|
+
"""Normalize automation_user_override flag.
|
|
242
|
+
|
|
243
|
+
Coerces any truthy value to True, falsy to False. Use when loading
|
|
244
|
+
user preferences or when accepting apply_client_preferences input.
|
|
245
|
+
"""
|
|
246
|
+
if isinstance(value, bool):
|
|
247
|
+
return value
|
|
248
|
+
if isinstance(value, str):
|
|
249
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
250
|
+
if isinstance(value, (int, float)):
|
|
251
|
+
return bool(value)
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
236
255
|
def normalize_automation_backend(value, *, automation_enabled: bool = True) -> str:
|
|
237
256
|
if not automation_enabled:
|
|
238
257
|
return BACKEND_NONE
|
|
@@ -380,6 +399,9 @@ def normalize_client_preferences(
|
|
|
380
399
|
schedule.get("automation_backend"),
|
|
381
400
|
automation_enabled=automation_enabled,
|
|
382
401
|
)
|
|
402
|
+
automation_user_override = normalize_automation_user_override(
|
|
403
|
+
schedule.get("automation_user_override")
|
|
404
|
+
)
|
|
383
405
|
install_preferences = normalize_client_install_preferences(
|
|
384
406
|
schedule.get("client_install_preferences")
|
|
385
407
|
)
|
|
@@ -392,6 +414,7 @@ def normalize_client_preferences(
|
|
|
392
414
|
"last_terminal_client": last_terminal_client,
|
|
393
415
|
"automation_enabled": automation_enabled,
|
|
394
416
|
"automation_backend": automation_backend,
|
|
417
|
+
"automation_user_override": automation_user_override,
|
|
395
418
|
"client_runtime_profiles": runtime_profiles,
|
|
396
419
|
"automation_task_profiles": normalize_automation_task_profiles(
|
|
397
420
|
schedule.get("automation_task_profiles")
|
|
@@ -429,6 +452,7 @@ def apply_client_preferences(
|
|
|
429
452
|
last_terminal_client: str | None = None,
|
|
430
453
|
automation_enabled=None,
|
|
431
454
|
automation_backend: str | None = None,
|
|
455
|
+
automation_user_override: bool | None = None,
|
|
432
456
|
client_runtime_profiles: dict | None = None,
|
|
433
457
|
automation_task_profiles: dict | None = None,
|
|
434
458
|
client_install_preferences: dict | None = None,
|
|
@@ -454,6 +478,11 @@ def apply_client_preferences(
|
|
|
454
478
|
automation_backend if automation_backend is not None else current["automation_backend"],
|
|
455
479
|
automation_enabled=merged["automation_enabled"],
|
|
456
480
|
)
|
|
481
|
+
merged["automation_user_override"] = normalize_automation_user_override(
|
|
482
|
+
automation_user_override
|
|
483
|
+
if automation_user_override is not None
|
|
484
|
+
else current.get("automation_user_override", False)
|
|
485
|
+
)
|
|
457
486
|
merged["client_runtime_profiles"] = normalize_client_runtime_profiles(
|
|
458
487
|
client_runtime_profiles
|
|
459
488
|
if client_runtime_profiles is not None
|
|
@@ -490,6 +519,7 @@ def save_client_preferences(
|
|
|
490
519
|
last_terminal_client: str | None = None,
|
|
491
520
|
automation_enabled=None,
|
|
492
521
|
automation_backend: str | None = None,
|
|
522
|
+
automation_user_override: bool | None = None,
|
|
493
523
|
client_runtime_profiles: dict | None = None,
|
|
494
524
|
automation_task_profiles: dict | None = None,
|
|
495
525
|
client_install_preferences: dict | None = None,
|
|
@@ -503,6 +533,7 @@ def save_client_preferences(
|
|
|
503
533
|
last_terminal_client=last_terminal_client,
|
|
504
534
|
automation_enabled=automation_enabled,
|
|
505
535
|
automation_backend=automation_backend,
|
|
536
|
+
automation_user_override=automation_user_override,
|
|
506
537
|
client_runtime_profiles=client_runtime_profiles,
|
|
507
538
|
automation_task_profiles=automation_task_profiles,
|
|
508
539
|
client_install_preferences=client_install_preferences,
|