nexo-brain 6.0.6 → 6.1.1
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/package.json +2 -2
- package/src/call_model_raw.py +342 -0
- package/src/cli.py +47 -4
- 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.
|
|
3
|
+
"version": "6.1.1",
|
|
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.
|
|
21
|
+
Version `6.1.1` is the current packaged-runtime line: small fix to `nexo --help` so the `Latest: vX` line reliably appears when NEXO Desktop invokes the CLI via subprocess — unblocks the Desktop Brain auto-update banner that previously couldn't parse the version delta. No behaviour change for interactive terminal users; the 6-hour registry cache still rate-limits network calls. Bundles all v6.1.0 Protocol Enforcer Fase 2 + multi-claude-sid hotfix content. 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/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.1",
|
|
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",
|
|
@@ -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
|
@@ -118,10 +118,23 @@ def _fetch_latest_version(timeout_seconds: int = 2) -> str | None:
|
|
|
118
118
|
|
|
119
119
|
|
|
120
120
|
def _should_refresh_latest_version() -> bool:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
"""Decide whether to hit the npm registry to refresh `latest` version.
|
|
122
|
+
|
|
123
|
+
Prior behaviour gated this on `isatty()` so `nexo --help` never made
|
|
124
|
+
a network call outside an interactive terminal. That also meant NEXO
|
|
125
|
+
Desktop — which spawns `nexo` via subprocess with piped stdio — could
|
|
126
|
+
never populate the version cache, so the Desktop update banner for
|
|
127
|
+
Brain never saw a newer `Latest: vX` line in the help output and no
|
|
128
|
+
Brain update was ever offered automatically (v6.1.1 fix).
|
|
129
|
+
|
|
130
|
+
The 6-hour `max_age_seconds` at `_load_latest_version_cache()` is the
|
|
131
|
+
real rate-limit. This function now returns True unconditionally so
|
|
132
|
+
missing/stale cache entries are always refreshed, regardless of tty
|
|
133
|
+
context. Fail-closed: `_fetch_latest_version` still catches every
|
|
134
|
+
subprocess error and returns None, so the help line falls back to
|
|
135
|
+
installed-only when npm is unreachable.
|
|
136
|
+
"""
|
|
137
|
+
return True
|
|
125
138
|
|
|
126
139
|
|
|
127
140
|
def _version_sort_key(raw: str) -> tuple[tuple[int, ...], int, str]:
|
|
@@ -2277,6 +2290,25 @@ def main():
|
|
|
2277
2290
|
dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
|
|
2278
2291
|
|
|
2279
2292
|
# -- desktop bridge (read-only, for NEXO Desktop and any external UI) --
|
|
2293
|
+
# Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
|
|
2294
|
+
quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
|
|
2295
|
+
quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
|
|
2296
|
+
|
|
2297
|
+
qlist_p = quarantine_sub.add_parser("list", help="List quarantine items")
|
|
2298
|
+
qlist_p.add_argument("--status", default="pending",
|
|
2299
|
+
choices=["pending", "promoted", "rejected", "expired", "all"])
|
|
2300
|
+
qlist_p.add_argument("--limit", type=int, default=20)
|
|
2301
|
+
qlist_p.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2302
|
+
|
|
2303
|
+
qpromote_p = quarantine_sub.add_parser("promote", help="Promote a quarantine item to STM")
|
|
2304
|
+
qpromote_p.add_argument("id", help="Quarantine item id")
|
|
2305
|
+
qpromote_p.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2306
|
+
|
|
2307
|
+
qreject_p = quarantine_sub.add_parser("reject", help="Reject a quarantine item")
|
|
2308
|
+
qreject_p.add_argument("id", help="Quarantine item id")
|
|
2309
|
+
qreject_p.add_argument("--reason", default="", help="Optional rejection reason")
|
|
2310
|
+
qreject_p.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2311
|
+
|
|
2280
2312
|
schema_parser = sub.add_parser("schema", help="Editable-field schema for Preferences UI")
|
|
2281
2313
|
schema_parser.add_argument("--json", action="store_true", help="JSON output (default)")
|
|
2282
2314
|
|
|
@@ -2410,6 +2442,17 @@ def main():
|
|
|
2410
2442
|
return _uninstall(args)
|
|
2411
2443
|
elif args.command == "dashboard":
|
|
2412
2444
|
return _dashboard(args)
|
|
2445
|
+
elif args.command == "quarantine":
|
|
2446
|
+
from desktop_bridge import cmd_quarantine_list, cmd_quarantine_promote, cmd_quarantine_reject
|
|
2447
|
+
if args.quarantine_command == "list":
|
|
2448
|
+
return cmd_quarantine_list(args)
|
|
2449
|
+
if args.quarantine_command == "promote":
|
|
2450
|
+
return cmd_quarantine_promote(args)
|
|
2451
|
+
if args.quarantine_command == "reject":
|
|
2452
|
+
return cmd_quarantine_reject(args)
|
|
2453
|
+
# No subcommand — show help.
|
|
2454
|
+
quarantine_parser.print_help()
|
|
2455
|
+
return 1
|
|
2413
2456
|
elif args.command in ("schema", "identity", "onboard", "scan-profile"):
|
|
2414
2457
|
from desktop_bridge import cmd_schema, cmd_identity, cmd_onboard, cmd_scan_profile
|
|
2415
2458
|
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,
|
package/src/cognitive/_memory.py
CHANGED
|
@@ -26,13 +26,52 @@ def _get_gate_stats():
|
|
|
26
26
|
from cognitive._ingest import get_gate_stats
|
|
27
27
|
return get_gate_stats()
|
|
28
28
|
|
|
29
|
+
def _compute_age_days(created_at) -> int | None:
|
|
30
|
+
"""Return integer days since ``created_at`` or None if unparseable.
|
|
31
|
+
|
|
32
|
+
Accepts ISO strings (with or without microseconds), naive or aware,
|
|
33
|
+
and epoch floats. Silent fallback to None so the formatter never
|
|
34
|
+
breaks on bad timestamps; callers that need the raw string still
|
|
35
|
+
have it on the result dict.
|
|
36
|
+
"""
|
|
37
|
+
if created_at is None or created_at == "":
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
if isinstance(created_at, (int, float)):
|
|
41
|
+
ts = datetime.fromtimestamp(float(created_at), tz=timezone.utc).replace(tzinfo=None)
|
|
42
|
+
else:
|
|
43
|
+
text = str(created_at).strip().replace("T", " ").replace("Z", "").split("+")[0]
|
|
44
|
+
if "." in text:
|
|
45
|
+
ts = datetime.strptime(text, "%Y-%m-%d %H:%M:%S.%f")
|
|
46
|
+
else:
|
|
47
|
+
ts = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
|
48
|
+
except (ValueError, TypeError):
|
|
49
|
+
return None
|
|
50
|
+
now = _utcnow_naive()
|
|
51
|
+
delta = now - ts
|
|
52
|
+
return max(0, int(delta.days))
|
|
53
|
+
|
|
54
|
+
|
|
29
55
|
def format_results(results: list[dict]) -> str:
|
|
30
|
-
"""Format search results with enriched context.
|
|
56
|
+
"""Format search results with enriched context.
|
|
57
|
+
|
|
58
|
+
Fase 2 R07 extension: each result is annotated with its age in days
|
|
59
|
+
so the reader can tell at-a-glance whether the information is fresh
|
|
60
|
+
or stale. Ages >= 7 days get a [stale:Nd] tag that callers (R24) can
|
|
61
|
+
use as a signal to verify against authoritative sources before
|
|
62
|
+
acting on the memory.
|
|
63
|
+
"""
|
|
31
64
|
if not results:
|
|
32
65
|
return "No results found."
|
|
33
66
|
|
|
34
67
|
lines = []
|
|
35
68
|
for r in results:
|
|
69
|
+
# R07: always annotate age_days on the result dict so the caller
|
|
70
|
+
# (handle_cognitive_retrieve, nexo_memory_recall, etc.) has a
|
|
71
|
+
# structured field to gate stale-memory behaviour on.
|
|
72
|
+
age_days = _compute_age_days(r.get("created_at"))
|
|
73
|
+
if age_days is not None:
|
|
74
|
+
r["age_days"] = age_days
|
|
36
75
|
score = r["score"]
|
|
37
76
|
stype = r["source_type"].upper()
|
|
38
77
|
domain = r.get("domain", "")
|
|
@@ -42,7 +81,13 @@ def format_results(results: list[dict]) -> str:
|
|
|
42
81
|
# Header
|
|
43
82
|
domain_str = f" ({domain})" if domain else ""
|
|
44
83
|
title_str = f': "{title}"' if title else ""
|
|
45
|
-
|
|
84
|
+
age_str = ""
|
|
85
|
+
if age_days is not None:
|
|
86
|
+
if age_days >= 7:
|
|
87
|
+
age_str = f" [stale:{age_days}d]"
|
|
88
|
+
else:
|
|
89
|
+
age_str = f" [{age_days}d]"
|
|
90
|
+
header = f"[{score:.2f}] {stype}{domain_str}{title_str}{age_str}"
|
|
46
91
|
|
|
47
92
|
# Content preview (300 chars)
|
|
48
93
|
preview = content[:300]
|
package/src/db/_schema.py
CHANGED
|
@@ -1041,6 +1041,47 @@ def _m42_v6_0_1_hotfix(conn):
|
|
|
1041
1041
|
)
|
|
1042
1042
|
|
|
1043
1043
|
|
|
1044
|
+
def _m43_session_claude_aliases(conn):
|
|
1045
|
+
"""Multi-Claude-sid-per-sid aliasing (hotfix for NEXO Desktop
|
|
1046
|
+
multi-conversation workflows).
|
|
1047
|
+
|
|
1048
|
+
NEXO Desktop spawns one `claude` CLI subprocess per conversation.
|
|
1049
|
+
Each spawn fires its own SessionStart hook with a fresh UUID. The
|
|
1050
|
+
legacy schema (``sessions.claude_session_id`` as a single TEXT
|
|
1051
|
+
column) can only remember ONE UUID per sid, so when a second
|
|
1052
|
+
conversation is opened, the hook's PreToolUse lookup
|
|
1053
|
+
(``_resolve_nexo_sid``) receives the wrong UUID and blocks the
|
|
1054
|
+
edit with "unknown target".
|
|
1055
|
+
|
|
1056
|
+
Fix: a 1-to-N alias table. ``nexo_startup`` now also writes
|
|
1057
|
+
``(sid, claude_session_id, first_seen, last_seen)`` here, and
|
|
1058
|
+
``_resolve_nexo_sid`` consults this table FIRST (falling back to
|
|
1059
|
+
the legacy ``sessions.claude_session_id`` column for backward
|
|
1060
|
+
compatibility with rows created before this migration).
|
|
1061
|
+
|
|
1062
|
+
Idempotent.
|
|
1063
|
+
"""
|
|
1064
|
+
conn.execute(
|
|
1065
|
+
"""CREATE TABLE IF NOT EXISTS session_claude_aliases (
|
|
1066
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1067
|
+
sid TEXT NOT NULL,
|
|
1068
|
+
claude_session_id TEXT NOT NULL,
|
|
1069
|
+
first_seen REAL NOT NULL,
|
|
1070
|
+
last_seen REAL NOT NULL,
|
|
1071
|
+
UNIQUE(sid, claude_session_id)
|
|
1072
|
+
)"""
|
|
1073
|
+
)
|
|
1074
|
+
_migrate_add_index(
|
|
1075
|
+
conn, "idx_claude_aliases_sid", "session_claude_aliases", "sid"
|
|
1076
|
+
)
|
|
1077
|
+
_migrate_add_index(
|
|
1078
|
+
conn,
|
|
1079
|
+
"idx_claude_aliases_claude_sid",
|
|
1080
|
+
"session_claude_aliases",
|
|
1081
|
+
"claude_session_id",
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
|
|
1044
1085
|
MIGRATIONS = [
|
|
1045
1086
|
(1, "learnings_columns", _m1_learnings_columns),
|
|
1046
1087
|
(2, "followups_reasoning", _m2_followups_reasoning),
|
|
@@ -1084,6 +1125,7 @@ MIGRATIONS = [
|
|
|
1084
1125
|
(40, "classification_columns", _m40_classification_columns),
|
|
1085
1126
|
(41, "automation_sessions_columns", _m41_automation_sessions_columns),
|
|
1086
1127
|
(42, "v6_0_1_hotfix", _m42_v6_0_1_hotfix),
|
|
1128
|
+
(43, "session_claude_aliases", _m43_session_claude_aliases),
|
|
1087
1129
|
]
|
|
1088
1130
|
|
|
1089
1131
|
|