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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/package.json +2 -2
  4. package/src/call_model_raw.py +342 -0
  5. package/src/cli.py +47 -4
  6. package/src/client_preferences.py +31 -0
  7. package/src/cognitive/_memory.py +47 -2
  8. package/src/db/_schema.py +42 -0
  9. package/src/desktop_bridge.py +58 -0
  10. package/src/enforcement_classifier.py +198 -0
  11. package/src/enforcement_engine.py +1497 -27
  12. package/src/guardian_config.py +284 -0
  13. package/src/guardian_telemetry.py +134 -0
  14. package/src/hook_guardrails.py +99 -9
  15. package/src/plugins/artifact_registry.py +118 -0
  16. package/src/plugins/protocol.py +68 -4
  17. package/src/plugins/simple_api.py +97 -3
  18. package/src/plugins/workflow.py +58 -0
  19. package/src/presets/entities_universal.json +235 -0
  20. package/src/presets/guardian_default.json +87 -0
  21. package/src/r13_pre_edit_guard.py +146 -0
  22. package/src/r14_correction_learning.py +95 -0
  23. package/src/r15_project_context.py +134 -0
  24. package/src/r16_declared_done.py +72 -0
  25. package/src/r17_promise_debt.py +67 -0
  26. package/src/r18_followup_autocomplete.py +118 -0
  27. package/src/r19_project_grep.py +103 -0
  28. package/src/r20_constant_change.py +147 -0
  29. package/src/r21_legacy_path.py +86 -0
  30. package/src/r22_personal_script.py +112 -0
  31. package/src/r23_ssh_without_atlas.py +103 -0
  32. package/src/r23b_deploy_vhost.py +113 -0
  33. package/src/r23c_cwd_mismatch.py +83 -0
  34. package/src/r23d_chown_chmod_recursive.py +96 -0
  35. package/src/r23e_force_push_main.py +92 -0
  36. package/src/r23f_db_no_where.py +112 -0
  37. package/src/r23g_secrets_in_output.py +72 -0
  38. package/src/r23h_shebang_mismatch.py +115 -0
  39. package/src/r23i_auto_deploy_ignored.py +65 -0
  40. package/src/r23j_global_install.py +104 -0
  41. package/src/r23k_script_duplicates_skill.py +55 -0
  42. package/src/r23l_resource_collision.py +112 -0
  43. package/src/r23m_message_duplicate.py +73 -0
  44. package/src/r24_stale_memory.py +75 -0
  45. package/src/r25_nora_maria_read_only.py +197 -0
  46. package/src/requirements.txt +8 -0
  47. package/src/resonance_map.py +8 -0
  48. package/src/resonance_tiers.json +4 -0
  49. package/src/server.py +28 -1
  50. package/src/tools_credentials.py +43 -3
  51. package/src/tools_learnings.py +43 -1
  52. package/src/tools_reminders_crud.py +168 -0
  53. package/src/tools_sessions.py +55 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.0.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.0.6` is the current packaged-runtime line: the installer no longer leaks `export PATH="$NEXO_HOME/bin:$PATH"` into the developer's real shell profile (`~/.bash_profile`, `~/.bashrc`, `~/.zshrc`) when `NEXO_HOME` points somewhere other than the canonical `$HOME/.nexo` the classic case being any pytest run with `NEXO_HOME=/tmp/pytest-xxx`. Both the Python path (`src/auto_update.py::_ensure_runtime_cli_in_shell`) and the two JavaScript twins in `bin/nexo-brain.js` (install Step 8 and the migration path that restores the operator alias) now consult `_should_skip_shell_profile_backfill()` / `shouldSkipShellProfileBackfill()` and skip the write whenever `NEXO_HOME` is non-canonical, with `NEXO_SKIP_SHELL_PROFILE=1` as an explicit escape hatch. Fresh installs at `$HOME/.nexo` are unaffected. Release also carries v6.0.5's strict-hook `unknown target` fix and the pytest CI gate that caught this regression in the first place.
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.0.6",
3
+ "version": "6.1.1",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
- "description": "NEXO Brain \u2014 Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
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
- try:
122
- return sys.stdout.isatty() or sys.stderr.isatty()
123
- except Exception:
124
- return False
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,
@@ -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
- header = f"[{score:.2f}] {stype}{domain_str}{title_str}"
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