nexo-brain 7.25.2 → 7.25.4

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.
@@ -18,6 +18,7 @@ MAIN_CLEANUP_STATE_KEY = "local_context_main_tables_drained"
18
18
  LOCAL_CONTEXT_TABLES: tuple[str, ...] = (
19
19
  "local_index_roots",
20
20
  "local_index_exclusions",
21
+ "local_index_file_type_rules",
21
22
  "local_index_jobs",
22
23
  "local_index_checkpoints",
23
24
  "local_index_state",
@@ -103,10 +104,53 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
103
104
  _m63_local_context_layer(conn)
104
105
  _m64_local_context_live_dirs(conn)
105
106
  _ensure_entity_dossier_schema(conn)
106
- conn.execute("PRAGMA user_version=64")
107
+ _ensure_local_context_v2_schema(conn)
108
+ conn.execute("PRAGMA user_version=65")
107
109
  conn.commit()
108
110
 
109
111
 
112
+ def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
113
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
114
+ return {str(row["name"] if isinstance(row, sqlite3.Row) else row[1]) for row in rows}
115
+
116
+
117
+ def _add_column_if_missing(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
118
+ if column in _table_columns(conn, table):
119
+ return
120
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
121
+
122
+
123
+ def _ensure_local_context_v2_schema(conn: sqlite3.Connection) -> None:
124
+ """Idempotent local-index v2 schema for managed roots and file type rules."""
125
+ _add_column_if_missing(conn, "local_index_roots", "source", "TEXT NOT NULL DEFAULT 'legacy'")
126
+ _add_column_if_missing(conn, "local_index_roots", "remote", "INTEGER NOT NULL DEFAULT 0")
127
+ _add_column_if_missing(conn, "local_index_roots", "seed_version", "INTEGER NOT NULL DEFAULT 1")
128
+ _add_column_if_missing(conn, "local_index_exclusions", "source", "TEXT NOT NULL DEFAULT 'legacy'")
129
+ _add_column_if_missing(conn, "local_index_exclusions", "kind", "TEXT NOT NULL DEFAULT 'folder'")
130
+ conn.executescript(
131
+ """
132
+ CREATE TABLE IF NOT EXISTS local_index_file_type_rules (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ extension TEXT NOT NULL,
135
+ action TEXT NOT NULL DEFAULT 'ignore',
136
+ source TEXT NOT NULL DEFAULT 'user',
137
+ priority INTEGER NOT NULL DEFAULT 0,
138
+ reason TEXT NOT NULL DEFAULT '',
139
+ created_at REAL NOT NULL,
140
+ updated_at REAL NOT NULL,
141
+ UNIQUE(extension, source)
142
+ );
143
+
144
+ CREATE INDEX IF NOT EXISTS idx_local_index_roots_source
145
+ ON local_index_roots(source, status);
146
+ CREATE INDEX IF NOT EXISTS idx_local_index_exclusions_source
147
+ ON local_index_exclusions(source);
148
+ CREATE INDEX IF NOT EXISTS idx_local_index_file_type_rules_ext
149
+ ON local_index_file_type_rules(extension, source);
150
+ """
151
+ )
152
+
153
+
110
154
  def _ensure_entity_dossier_schema(conn: sqlite3.Connection) -> None:
111
155
  conn.executescript(
112
156
  """
@@ -68,6 +68,20 @@ def _read_text(path: Path) -> str:
68
68
  return data.decode("utf-8", errors="replace")[:MAX_CHARS]
69
69
 
70
70
 
71
+ def _read_text_if_safe(path: Path) -> str:
72
+ data = path.read_bytes()[:MAX_TEXT_BYTES]
73
+ if not data or b"\x00" in data[:8192]:
74
+ return ""
75
+ text = _read_text(path)
76
+ if not text:
77
+ return ""
78
+ printable = sum(1 for char in text[:4096] if char.isprintable() or char.isspace())
79
+ sample_len = max(1, min(len(text), 4096))
80
+ if printable / sample_len < 0.85:
81
+ return ""
82
+ return text
83
+
84
+
71
85
  def _extract_csv(path: Path) -> str:
72
86
  text = _read_text(path)
73
87
  rows = []
@@ -291,7 +305,9 @@ def extract_text(path: Path) -> tuple[str, dict]:
291
305
  elif suffix == ".xlsx":
292
306
  text = _extract_xlsx(path)
293
307
  else:
294
- text = ""
308
+ text = _read_text_if_safe(path)
309
+ if text:
310
+ metadata["extractor"] = "generic_text"
295
311
  if contains_secret(text):
296
312
  metadata["content_secret_detected"] = True
297
313
  return clean_text(text), metadata
@@ -12,6 +12,12 @@ import time
12
12
  from pathlib import Path
13
13
 
14
14
  from runtime_home import export_resolved_nexo_home
15
+ try:
16
+ from product_mode import desktop_product_requested
17
+ except Exception: # pragma: no cover - stale packaged runtimes may miss product_mode during update
18
+ def desktop_product_requested() -> bool:
19
+ return str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1"
20
+
15
21
  from runtime_versioning import (
16
22
  activate_versioned_runtime_snapshot,
17
23
  compute_mcp_runtime_fingerprint,
@@ -239,15 +245,94 @@ def _venv_pip_path(runtime_root: Path | None = None) -> Path:
239
245
  return root / ".venv" / "bin" / "pip"
240
246
 
241
247
 
248
+ def _python_version_tuple(python_bin: Path | str) -> tuple[int, int, int] | None:
249
+ try:
250
+ result = subprocess.run(
251
+ [str(python_bin), "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"],
252
+ capture_output=True,
253
+ text=True,
254
+ timeout=15,
255
+ )
256
+ except Exception:
257
+ return None
258
+ if result.returncode != 0:
259
+ return None
260
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout or result.stderr or "")
261
+ if not match:
262
+ return None
263
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
264
+
265
+
266
+ def _managed_venv_python_supported(python_bin: Path | str) -> bool:
267
+ version = _python_version_tuple(python_bin)
268
+ if not version:
269
+ return False
270
+ if desktop_product_requested():
271
+ return version[:2] == (3, 12)
272
+ return version >= (3, 10, 0)
273
+
274
+
275
+ def _resolve_managed_venv_base_python() -> str:
276
+ candidates = [
277
+ os.environ.get("NEXO_BOOTSTRAP_PYTHON", ""),
278
+ os.environ.get("NEXO_RUNTIME_PYTHON", ""),
279
+ os.environ.get("NEXO_PYTHON", ""),
280
+ ]
281
+ if desktop_product_requested():
282
+ candidates.extend([
283
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12",
284
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
285
+ "/opt/homebrew/bin/python3.12",
286
+ "/usr/local/bin/python3.12",
287
+ shutil.which("python3.12") or "",
288
+ ])
289
+ candidates.extend([sys.executable, shutil.which("python3") or ""])
290
+ for candidate in candidates:
291
+ clean = str(candidate or "").strip()
292
+ if clean and Path(clean).exists() and _managed_venv_python_supported(clean):
293
+ return clean
294
+ return sys.executable
295
+
296
+
297
+ def _archive_incompatible_managed_venv(root: Path, reason: str = "incompatible-python") -> Path | None:
298
+ venv_dir = root / ".venv"
299
+ if not venv_dir.exists():
300
+ return None
301
+ backup_root = root / "runtime" / "backups"
302
+ stamp = time.strftime("%Y%m%d-%H%M%S")
303
+ try:
304
+ backup_root.mkdir(parents=True, exist_ok=True)
305
+ target = backup_root / f"venv-{reason}-{stamp}"
306
+ counter = 2
307
+ while target.exists():
308
+ target = backup_root / f"venv-{reason}-{stamp}-{counter}"
309
+ counter += 1
310
+ shutil.move(str(venv_dir), str(target))
311
+ return target
312
+ except Exception:
313
+ return None
314
+
315
+
242
316
  def _ensure_managed_venv(runtime_root: Path | None = None) -> str | None:
243
317
  root = runtime_root or _nexo_home()
244
318
  venv_python = _venv_python_path(root)
319
+ if venv_python.exists():
320
+ if _managed_venv_python_supported(venv_python):
321
+ return None
322
+ version = _python_version_tuple(venv_python)
323
+ reason = f"python-{'.'.join(map(str, version[:2]))}" if version else "unreadable-python"
324
+ archived = _archive_incompatible_managed_venv(root, reason=reason)
325
+ if archived is None:
326
+ return "managed venv uses an incompatible Python and could not be archived"
327
+ base_python = _resolve_managed_venv_base_python()
328
+ if not _managed_venv_python_supported(base_python):
329
+ return f"no supported Python found for managed venv: {base_python}"
245
330
  if venv_python.exists():
246
331
  return None
247
332
  try:
248
333
  root.mkdir(parents=True, exist_ok=True)
249
334
  result = subprocess.run(
250
- [sys.executable, "-m", "venv", str(root / ".venv")],
335
+ [base_python, "-m", "venv", str(root / ".venv")],
251
336
  capture_output=True,
252
337
  text=True,
253
338
  timeout=120,
@@ -676,6 +761,8 @@ def _reinstall_pip_deps() -> str | None:
676
761
  if alt_pip.exists():
677
762
  venv_pip = alt_pip
678
763
  if not venv_pip.exists():
764
+ if desktop_product_requested():
765
+ return "managed Desktop venv pip is unavailable after repair"
679
766
  # No venv, try system pip with --break-system-packages
680
767
  try:
681
768
  result = subprocess.run(
package/src/server.py CHANGED
@@ -1026,6 +1026,32 @@ def nexo_local_index_exclusions(action: str = "list", path: str = "", reason: st
1026
1026
  return json.dumps(result, ensure_ascii=False)
1027
1027
 
1028
1028
 
1029
+ @mcp.tool
1030
+ def nexo_local_index_filetypes(action: str = "list", extension: str = "", mode: str = "extract", reason: str = "user") -> str:
1031
+ """List, include, exclude or reset local memory file extension rules."""
1032
+ normalized = str(action or "list").strip().lower()
1033
+ if normalized == "list":
1034
+ result = local_context_api.list_file_type_rules(readonly=True)
1035
+ elif normalized in {"include", "add"}:
1036
+ result = local_context_api.set_file_type_rule(extension, action=mode or "extract", reason=reason)
1037
+ elif normalized in {"exclude", "ignore"}:
1038
+ result = local_context_api.set_file_type_rule(extension, action="ignore", reason=reason)
1039
+ elif normalized in {"remove", "delete"}:
1040
+ result = local_context_api.remove_file_type_rule(extension)
1041
+ elif normalized == "reset":
1042
+ result = local_context_api.reset_file_type_rules()
1043
+ else:
1044
+ result = {"ok": False, "error": "unknown_action", "allowed": ["list", "include", "exclude", "remove", "reset"]}
1045
+ return json.dumps(result, ensure_ascii=False)
1046
+
1047
+
1048
+ @mcp.tool
1049
+ def nexo_local_index_migrate_roots_v2(apply: bool = False) -> str:
1050
+ """Plan or apply Local Memory roots v2 cleanup."""
1051
+ result = local_context_api.migrate_roots_seed_v2(dry_run=not bool(apply))
1052
+ return json.dumps(result, ensure_ascii=False)
1053
+
1054
+
1029
1055
  @mcp.tool
1030
1056
  def nexo_local_context(
1031
1057
  query: str,
@@ -2502,6 +2502,37 @@
2502
2502
  },
2503
2503
  "triggers_after": []
2504
2504
  },
2505
+ "nexo_local_index_filetypes": {
2506
+ "description": "List, include, exclude, remove, or reset Local Context file extension rules",
2507
+ "category": "local_context",
2508
+ "source": "server",
2509
+ "requires": [],
2510
+ "provides": [
2511
+ "local_index_file_type_rules"
2512
+ ],
2513
+ "internal_calls": [],
2514
+ "enforcement": {
2515
+ "level": "none",
2516
+ "rules": []
2517
+ },
2518
+ "triggers_after": []
2519
+ },
2520
+ "nexo_local_index_migrate_roots_v2": {
2521
+ "description": "Plan or apply Local Context roots v2 cleanup",
2522
+ "category": "local_context",
2523
+ "source": "server",
2524
+ "requires": [],
2525
+ "provides": [
2526
+ "local_index_roots",
2527
+ "local_index_cleanup"
2528
+ ],
2529
+ "internal_calls": [],
2530
+ "enforcement": {
2531
+ "level": "none",
2532
+ "rules": []
2533
+ },
2534
+ "triggers_after": []
2535
+ },
2505
2536
  "nexo_local_index_models": {
2506
2537
  "description": "Inspect or warm Local Context local model availability",
2507
2538
  "category": "local_context",