nexo-brain 7.20.9 → 7.20.11

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.9",
3
+ "version": "7.20.11",
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,11 @@
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 `7.20.9` is the current packaged-runtime line. Patch release over v7.20.8 — Local Context scans automatic roots at full operational depth, falls back to crontab when Linux/WSL systemd user timers fail, passes Windows AppData email roots into WSL, and blocks Google API keys before HTML cleaning.
21
+ Version `7.20.11` is the current packaged-runtime line. Patch release over v7.20.10 — Local Context now starts from real system volume roots plus mounted/removable/network volumes, filters system/cache/app/product artifacts, and injects relevant local evidence automatically into heartbeat, task-open and pre-action context.
22
+
23
+ Previously in `7.20.10`: patch release over v7.20.9 — Local Context manual refreshes now reconcile automatic roots every time, so newly mounted disks and upgraded default roots are picked up immediately from Desktop's "comprobar cambios" path.
24
+
25
+ Previously in `7.20.9`: patch release over v7.20.8 — Local Context scans automatic roots at full operational depth, falls back to crontab when Linux/WSL systemd user timers fail, passes Windows AppData email roots into WSL, and blocks Google API keys before HTML cleaning.
22
26
 
23
27
  Previously in `7.20.8`: patch release over v7.20.7 — Local Context recognises Windows Mail package roots and Outlook Mac profile roots as bounded local-email sources instead of rejecting them as generic AppData / Group Containers.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.9",
3
+ "version": "7.20.11",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
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",
@@ -16,7 +16,7 @@ from db._schema import run_migrations
16
16
  from . import embeddings
17
17
  from .extractors import chunk_text, contains_secret, entities, extract_text, summarize
18
18
  from .logging import log_event, tail
19
- from .privacy import classify_path, is_queryable_path, should_extract, should_skip_file, should_skip_tree
19
+ from .privacy import classify_path, is_local_email_tree, is_queryable_path, should_extract, should_skip_file, should_skip_tree
20
20
  from .util import content_hash, json_dumps, json_loads, norm_path, now, quick_fingerprint, redact_path, stable_id, system_label, tokenize
21
21
 
22
22
  LOCAL_INDEX_SERVICE_LABEL = "com.nexo.local-index"
@@ -29,6 +29,7 @@ DEFAULT_LIVE_FILE_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_FILE_LIMIT",
29
29
  DEFAULT_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_DEFAULT_DEPTH", "24") or "24")
30
30
  DEFAULT_EMAIL_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_EMAIL_ROOT_DEPTH", "24") or "24")
31
31
  DEFAULT_MOUNTED_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_MOUNTED_ROOT_DEPTH", "24") or "24")
32
+ DEFAULT_SYSTEM_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_SYSTEM_ROOT_DEPTH", "24") or "24")
32
33
 
33
34
 
34
35
  def ensure_ready() -> None:
@@ -44,7 +45,7 @@ def _conn():
44
45
  def add_root(path: str, *, mode: str = "normal", depth: int | None = None) -> dict:
45
46
  conn = _conn()
46
47
  root_path = norm_path(path)
47
- if should_skip_tree(root_path):
48
+ if should_skip_tree(root_path) and not _allow_explicit_blocked_root(root_path):
48
49
  log_event("warn", "root_rejected_private", "Root rejected by local memory privacy rules", path=redact_path(root_path))
49
50
  return {"ok": False, "error": "root_blocked_by_privacy", "root_path": root_path}
50
51
  depth_value = 2 if depth is None else int(depth)
@@ -141,6 +142,18 @@ def _mounted_volume_roots() -> list[str]:
141
142
  return roots
142
143
 
143
144
 
145
+ def _system_volume_roots() -> list[str]:
146
+ if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_SYSTEM_ROOTS", "").strip() in {"1", "true", "yes"}:
147
+ return []
148
+ if sys.platform == "darwin":
149
+ return ["/"]
150
+ if sys.platform.startswith("win"):
151
+ # Windows roots are discovered as mounted drive roots so mapped drives
152
+ # and removable disks share the same code path.
153
+ return []
154
+ return ["/"]
155
+
156
+
144
157
  def _local_email_roots() -> list[str]:
145
158
  home = Path.home()
146
159
  roots: list[Path] = [home / ".nexo" / "runtime" / "nexo-email"]
@@ -180,14 +193,15 @@ def default_roots() -> list[str]:
180
193
  def default_root_specs() -> list[tuple[str, int]]:
181
194
  home = Path.home()
182
195
  configured = os.environ.get("NEXO_LOCAL_INDEX_DEFAULT_ROOTS", "").strip()
183
- if configured:
184
- return _dedupe_root_specs(
185
- [(item, DEFAULT_ROOT_DEPTH) for item in configured.split(os.pathsep) if item.strip()]
186
- )
196
+ system_specs = [(root, DEFAULT_SYSTEM_ROOT_DEPTH) for root in _system_volume_roots()]
197
+ mounted_specs = [(root, DEFAULT_MOUNTED_ROOT_DEPTH) for root in _mounted_volume_roots()]
198
+ configured_specs = [(item, DEFAULT_ROOT_DEPTH) for item in configured.split(os.pathsep) if item.strip()]
199
+ base_specs = system_specs + mounted_specs + configured_specs
200
+ if not base_specs:
201
+ base_specs = [(str(home), DEFAULT_ROOT_DEPTH)]
187
202
  return _dedupe_root_specs(
188
- [(str(home), DEFAULT_ROOT_DEPTH)]
203
+ base_specs
189
204
  + [(root, DEFAULT_EMAIL_ROOT_DEPTH) for root in _local_email_roots()]
190
- + [(root, DEFAULT_MOUNTED_ROOT_DEPTH) for root in _mounted_volume_roots()]
191
205
  )
192
206
 
193
207
 
@@ -525,6 +539,15 @@ def _is_paused() -> bool:
525
539
  return _get_state("paused", "0") == "1"
526
540
 
527
541
 
542
+ def _allow_explicit_blocked_root(path: str) -> bool:
543
+ # Test and controlled diagnostics may explicitly index a temporary fixture
544
+ # root while production root discovery still skips temp/system trees.
545
+ if os.environ.get("NEXO_LOCAL_INDEX_ALLOW_BLOCKED_ROOTS", "").strip().lower() not in {"1", "true", "yes"}:
546
+ return False
547
+ normalized = norm_path(path).replace("\\", "/").lower()
548
+ return any(marker in normalized for marker in ("/tmp/", "/var/folders/", "/private/var/folders/"))
549
+
550
+
528
551
  def _is_excluded(path: str, exclusions: list[str]) -> bool:
529
552
  value = norm_path(path)
530
553
  return any(value == item or value.startswith(item + os.sep) for item in exclusions)
@@ -535,6 +558,47 @@ def _path_prefix(path: str) -> str:
535
558
  return normalized + os.sep if normalized else os.sep
536
559
 
537
560
 
561
+ def _is_nested_path(path: str, parent: str) -> bool:
562
+ value = norm_path(path)
563
+ base = norm_path(parent)
564
+ if not value or not base or value == base:
565
+ return False
566
+ if base == os.sep:
567
+ return value.startswith(os.sep)
568
+ return value.startswith(_path_prefix(base))
569
+
570
+
571
+ def _is_discovered_mount_path(path: str) -> bool:
572
+ value = norm_path(path).replace("\\", "/").lower()
573
+ if not value:
574
+ return False
575
+ return (
576
+ value.startswith("/volumes/")
577
+ or value.startswith("/mnt/")
578
+ or value.startswith("/media/")
579
+ or value.startswith("/run/media/")
580
+ or (len(value) == 3 and value[1:] == ":/")
581
+ or (len(value) == 3 and value[1:] == ":\\")
582
+ )
583
+
584
+
585
+ def _effective_scan_roots(roots: list[dict]) -> list[dict]:
586
+ active_roots = [root for root in roots if str(root.get("status") or "active") != "removed"]
587
+ parent_paths = [str(root.get("root_path") or "") for root in active_roots]
588
+ effective: list[dict] = []
589
+ for root in active_roots:
590
+ root_path = str(root.get("root_path") or "")
591
+ if _is_discovered_mount_path(root_path):
592
+ effective.append(root)
593
+ continue
594
+ if root_path and not is_local_email_tree(root_path) and any(
595
+ _is_nested_path(root_path, parent) for parent in parent_paths
596
+ ):
597
+ continue
598
+ effective.append(root)
599
+ return effective
600
+
601
+
538
602
  def _file_type(path: Path) -> str:
539
603
  if path.is_dir():
540
604
  return "folder"
@@ -1188,14 +1252,14 @@ def scan_once(*, limit: int | None = None) -> dict:
1188
1252
  log_event("info", "scan_skipped_paused", "Local memory scan skipped because indexing is paused")
1189
1253
  return {"ok": True, "paused": True, "roots": 0, "seen": 0, "changed": 0, "errors": 0, "partial": False}
1190
1254
  started = now()
1191
- roots = list_roots()
1255
+ roots = _effective_scan_roots(list_roots())
1192
1256
  exclusions = [row["path"] for row in list_exclusions()]
1193
1257
  totals = {"roots": len(roots), "seen": 0, "changed": 0, "errors": 0, "partial": False}
1194
1258
  log_event("info", "scan_started", "Local memory scan started", roots=len(roots))
1195
1259
  for root in roots:
1196
1260
  root_path = Path(root["root_path"]).expanduser()
1197
1261
  root_id = int(root["id"])
1198
- if should_skip_tree(str(root_path)):
1262
+ if should_skip_tree(str(root_path)) and not _allow_explicit_blocked_root(str(root_path)):
1199
1263
  conn.execute(
1200
1264
  "UPDATE local_index_roots SET status='removed', last_scan_at=?, updated_at=? WHERE id=?",
1201
1265
  (now(), now(), root_id),
@@ -1455,14 +1519,13 @@ def run_once(
1455
1519
  if _get_state("privacy_hygiene_v2", "0") != "1":
1456
1520
  local_index_privacy_hygiene(fix=True)
1457
1521
  _set_state("privacy_hygiene_v2", "1")
1458
- if root:
1459
- add_root(root)
1460
- elif (
1522
+ if (
1461
1523
  os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1"
1462
1524
  and os.environ.get("NEXO_SKIP_FS_INDEX", "").strip() != "1"
1463
- and not list_roots()
1464
1525
  ):
1465
1526
  ensure_default_roots()
1527
+ if root:
1528
+ add_root(root)
1466
1529
  live_result = reconcile_live_changes(
1467
1530
  asset_limit=live_asset_limit,
1468
1531
  dir_limit=live_dir_limit,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
 
5
6
  SENSITIVE_FILE_NAMES = {
@@ -124,6 +125,15 @@ NOISY_PARTS = {
124
125
  "target",
125
126
  }
126
127
 
128
+ PRODUCT_ARTIFACT_PARTS = {
129
+ "brain-bundle",
130
+ "nexo desktop qa backups",
131
+ "nexo desktop qa.app",
132
+ "nexo desktop.app",
133
+ "nexo desktop beta.app",
134
+ "nexo desktop support.app",
135
+ }
136
+
127
137
  TRANSIENT_PARTS = {"tmp", "temp"}
128
138
 
129
139
  PRIVATE_PROFILE_PARTS = {
@@ -184,10 +194,47 @@ SYSTEM_PARTS = {
184
194
  "windows",
185
195
  "program files",
186
196
  "program files (x86)",
187
- "library/caches",
188
- "system/library",
197
+ }
198
+
199
+ SYSTEM_PATH_MARKERS = {
200
+ "/bin",
201
+ "/cores",
202
+ "/dev",
203
+ "/library/caches",
204
+ "/library/logs",
205
+ "/private/tmp",
206
+ "/private/var/db",
207
+ "/private/var/folders",
208
+ "/private/var/log",
209
+ "/private/var/vm",
189
210
  "/proc",
211
+ "/run",
212
+ "/sbin",
190
213
  "/sys",
214
+ "/system",
215
+ "/tmp",
216
+ "/usr",
217
+ "/var/folders",
218
+ "/var/tmp",
219
+ }
220
+
221
+ TEMP_SYSTEM_PATH_MARKERS = {
222
+ "/private/var/folders",
223
+ "/tmp",
224
+ "/var/folders",
225
+ "/var/tmp",
226
+ }
227
+
228
+ HOME_PRIVATE_PROFILE_MARKERS = {
229
+ "library/application support",
230
+ "library/containers",
231
+ "library/group containers",
232
+ "library/keychains",
233
+ "library/logs",
234
+ "library/mail",
235
+ "library/messages",
236
+ "library/safari",
237
+ "library/saved application state",
191
238
  }
192
239
 
193
240
 
@@ -210,6 +257,33 @@ def _is_under_marker(lowered: str, marker: str) -> bool:
210
257
  return lowered.endswith("/" + marker) or f"/{marker}/" in lowered
211
258
 
212
259
 
260
+ def _has_absolute_marker(lowered: str, marker: str) -> bool:
261
+ marker = "/" + marker.strip("/").lower()
262
+ return lowered == marker or lowered.endswith(marker) or f"{marker}/" in lowered
263
+
264
+
265
+ def _is_system_path(path: str) -> bool:
266
+ lowered = _normalized(path)
267
+ parts = _parts(path)
268
+ if parts & SYSTEM_PARTS:
269
+ return True
270
+ matched_markers = {marker for marker in SYSTEM_PATH_MARKERS if _has_absolute_marker(lowered, marker)}
271
+ if not matched_markers:
272
+ return False
273
+ if (
274
+ matched_markers <= TEMP_SYSTEM_PATH_MARKERS
275
+ and os.environ.get("NEXO_LOCAL_INDEX_ALLOW_BLOCKED_ROOTS", "").strip().lower() in {"1", "true", "yes"}
276
+ and "pytest-" in lowered
277
+ ):
278
+ return False
279
+ return True
280
+
281
+
282
+ def _is_app_bundle_path(path: str) -> bool:
283
+ lowered = _normalized(path)
284
+ return lowered.endswith(".app") or ".app/" in lowered
285
+
286
+
213
287
  def _is_inside_windows_mail_package(lowered: str) -> bool:
214
288
  return "/appdata/local/packages/microsoft.windowscommunicationsapps" in lowered
215
289
 
@@ -323,9 +397,12 @@ def is_sensitive_path(path: str) -> bool:
323
397
  def is_private_profile_path(path: str) -> bool:
324
398
  lowered = _normalized(path)
325
399
  parts = _parts(path)
326
- if parts & PRIVATE_PROFILE_PARTS:
400
+ global_parts = PRIVATE_PROFILE_PARTS - HOME_PRIVATE_PROFILE_MARKERS
401
+ if parts & global_parts:
402
+ return True
403
+ if _contains_path_marker(lowered, global_parts):
327
404
  return True
328
- if _contains_path_marker(lowered, PRIVATE_PROFILE_PARTS):
405
+ if (_is_home_hidden_path(path) or "/users/" in lowered) and _contains_path_marker(lowered, HOME_PRIVATE_PROFILE_MARKERS):
329
406
  return True
330
407
  name = Path(path).name.lower()
331
408
  if name in PROFILE_HIDDEN_FILE_NAMES:
@@ -346,8 +423,12 @@ def classify_path(path: str) -> tuple[int, str, str]:
346
423
  return 1, "sensitive_inventory_only", "sensitive_path"
347
424
  if is_private_profile_path(path):
348
425
  return 0, "private_profile_blocked", "private_profile_path"
349
- if any(item in lowered for item in SYSTEM_PARTS):
426
+ if _is_system_path(path):
350
427
  return 0, "system_blocked", "system_path"
428
+ if parts & PRODUCT_ARTIFACT_PARTS:
429
+ return 1, "inventory_only", "product_artifact"
430
+ if _is_app_bundle_path(path):
431
+ return 1, "inventory_only", "app_bundle"
351
432
  if parts & NOISY_PARTS or _has_transient_project_part(path) or _has_hidden_dir_part(path):
352
433
  return 1, "inventory_only", "noisy_tree"
353
434
  return 2, "normal", "default"
@@ -358,11 +439,17 @@ def should_skip_tree(path: str) -> bool:
358
439
  parts = _parts(path)
359
440
  if is_local_email_tree(path):
360
441
  return False
361
- if any(item in lowered for item in SYSTEM_PARTS):
442
+ if _is_system_path(path):
362
443
  return True
363
444
  if is_sensitive_path(path) or is_private_profile_path(path):
364
445
  return True
365
- return bool(parts & NOISY_PARTS or _has_transient_project_part(path) or _has_hidden_dir_part(path))
446
+ return bool(
447
+ parts & PRODUCT_ARTIFACT_PARTS
448
+ or _is_app_bundle_path(path)
449
+ or parts & NOISY_PARTS
450
+ or _has_transient_project_part(path)
451
+ or _has_hidden_dir_part(path)
452
+ )
366
453
 
367
454
 
368
455
  def should_skip_file(path: str) -> bool:
@@ -370,11 +457,17 @@ def should_skip_file(path: str) -> bool:
370
457
  parts = _parts(path)
371
458
  if is_local_email_tree(path):
372
459
  return not is_allowed_local_email_file(path)
373
- if any(item in lowered for item in SYSTEM_PARTS):
460
+ if _is_system_path(path):
374
461
  return True
375
462
  if is_sensitive_path(path) or is_private_profile_path(path):
376
463
  return True
377
- return bool(parts & NOISY_PARTS or _has_transient_project_part(path) or _has_hidden_dir_part(path))
464
+ return bool(
465
+ parts & PRODUCT_ARTIFACT_PARTS
466
+ or _is_app_bundle_path(path)
467
+ or parts & NOISY_PARTS
468
+ or _has_transient_project_part(path)
469
+ or _has_hidden_dir_part(path)
470
+ )
378
471
 
379
472
 
380
473
  def is_queryable_path(path: str, privacy_class: str = "") -> bool:
@@ -42,6 +42,11 @@ from plugins.guard import handle_guard_check
42
42
  from protocol_settings import get_protocol_strictness
43
43
  from tools_sessions import handle_heartbeat
44
44
 
45
+ try:
46
+ from tools_hot_context import append_local_context_evidence
47
+ except Exception: # pragma: no cover - local context is optional in early boot
48
+ append_local_context_evidence = None
49
+
45
50
 
46
51
  # ── R03 (Fase 2 Protocol Enforcer) evidence quality thresholds ────────
47
52
  # "evidence" supplied to nexo_task_close must be substantive when an
@@ -1320,6 +1325,14 @@ def handle_task_open(
1320
1325
  response_contract = dict(response_contract)
1321
1326
  response_contract["next_action"] = next_action
1322
1327
 
1328
+ recent_excerpt = format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else ""
1329
+ if append_local_context_evidence is not None:
1330
+ recent_excerpt = append_local_context_evidence(
1331
+ recent_excerpt,
1332
+ " | ".join(part for part in [clean_goal, context_hint.strip()] if part),
1333
+ limit=4,
1334
+ )
1335
+
1323
1336
  response = {
1324
1337
  "ok": True,
1325
1338
  "task_id": task["task_id"],
@@ -1351,8 +1364,8 @@ def handle_task_open(
1351
1364
  "response_contract": response_contract,
1352
1365
  "decision_support": decision_support,
1353
1366
  "recent_context": {
1354
- "has_matches": bool(recent_bundle.get("has_matches")),
1355
- "excerpt": format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else "",
1367
+ "has_matches": bool(recent_bundle.get("has_matches") or recent_excerpt.strip()),
1368
+ "excerpt": recent_excerpt,
1356
1369
  },
1357
1370
  "area_context": area_context if area_context.get("has_context") else None,
1358
1371
  "contract": {
@@ -15,6 +15,11 @@ from db import (
15
15
  )
16
16
 
17
17
 
18
+ def append_local_context_evidence(rendered: str, query: str, *, limit: int = 4) -> str:
19
+ local_context = _format_local_context_evidence(query, limit=min(limit or 4, 4))
20
+ return f"{rendered}{local_context}" if local_context else rendered
21
+
22
+
18
23
  def _format_local_context_evidence(query: str, *, limit: int = 4) -> str:
19
24
  clean_query = (query or "").strip()
20
25
  if not clean_query:
@@ -40,6 +45,14 @@ def _format_local_context_evidence(query: str, *, limit: int = 4) -> str:
40
45
  summary = str(asset.get("summary") or "").strip()
41
46
  suffix = f" — {summary[:180]}" if summary else ""
42
47
  lines.append(f"- {display_path} ({asset.get('file_type', 'file')}, score={score}){suffix}")
48
+ chunks = result.get("chunks") or []
49
+ if chunks:
50
+ lines.append("Relevant excerpts:")
51
+ for chunk in chunks[:limit]:
52
+ text = " ".join(str(chunk.get("text") or "").split())
53
+ if not text:
54
+ continue
55
+ lines.append(f"- {text[:360]}")
43
56
  refs = result.get("evidence_refs") or []
44
57
  if refs:
45
58
  lines.append(f"Evidence refs: {', '.join(str(ref) for ref in refs[:limit])}")
@@ -160,8 +173,7 @@ def handle_pre_action_context(
160
173
  limit=limit,
161
174
  )
162
175
  rendered = format_pre_action_context_bundle(bundle)
163
- local_context = _format_local_context_evidence(" | ".join(query_bits), limit=min(limit or 4, 4))
164
- return f"{rendered}{local_context}" if local_context else rendered
176
+ return append_local_context_evidence(rendered, " | ".join(query_bits), limit=limit)
165
177
 
166
178
 
167
179
  def handle_recent_context_resolve(
@@ -20,6 +20,11 @@ from db import (
20
20
  capture_context_event, maintain_memory_observations,
21
21
  )
22
22
 
23
+ try:
24
+ from tools_hot_context import append_local_context_evidence
25
+ except Exception: # pragma: no cover - local context is optional during bootstrap
26
+ append_local_context_evidence = None
27
+
23
28
  try:
24
29
  from r14_correction_learning import detect_correction as _detect_correction_semantic
25
30
  except Exception: # pragma: no cover - optional runtime dependency
@@ -707,6 +712,11 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
707
712
  if bundle.get("has_matches"):
708
713
  parts.append("")
709
714
  parts.append(format_pre_action_context_bundle(bundle, compact=True))
715
+ if append_local_context_evidence is not None:
716
+ local_rendered = append_local_context_evidence("", recent_query, limit=4).strip()
717
+ if local_rendered:
718
+ parts.append("")
719
+ parts.append(local_rendered)
710
720
  except Exception:
711
721
  pass
712
722