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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/bin/nexo-brain.js +25 -7
- package/package.json +1 -1
- package/src/auto_update.py +94 -8
- package/src/cli.py +66 -0
- package/src/db/_schema.py +23 -0
- package/src/doctor/providers/boot.py +106 -0
- package/src/local_context/__init__.py +10 -0
- package/src/local_context/api.py +832 -47
- package/src/local_context/db.py +45 -1
- package/src/local_context/extractors.py +17 -1
- package/src/plugins/update.py +88 -1
- package/src/server.py +26 -0
- package/tool-enforcement-map.json +31 -0
package/src/local_context/db.py
CHANGED
|
@@ -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
|
|
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
|
package/src/plugins/update.py
CHANGED
|
@@ -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
|
-
[
|
|
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",
|