nexo-brain 7.20.22 → 7.20.24

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.19",
3
+ "version": "7.20.24",
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.19` is the current packaged-runtime line. Patch release over v7.20.18 — Local Memory status and long first-indexing runs stay stable during Desktop-managed updates; stale macOS Full Disk Access denials are cleared after a live access probe succeeds.
21
+ Version `7.20.24` is the current packaged-runtime line. Patch release over v7.20.23 — Local Memory performance profile writes now tolerate active indexing, retry transient SQLite busy states, and shorten indexer write locks between processed files.
22
+
23
+ Previously in `7.20.23`: patch release over v7.20.22 — Local Memory status reads the real split sidecar database read-only, reports retryable keyed failures without false zeroes, and keeps Desktop Spanish/English copy localized.
24
+
25
+ Previously in `7.20.22`: patch release over v7.20.19 — Local Memory moved out of the main Brain database, MCP readiness verifies required tools, and split-aware Desktop backups validate the main DB and Local Memory sidecar separately.
22
26
 
23
27
  Previously in `7.20.18`: patch release over v7.20.17 — Desktop-managed setup now preserves a completed onboarding flag when Brain is later invoked with the non-interactive `--skip` bootstrap path.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.22",
3
+ "version": "7.20.24",
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",
@@ -99,15 +99,13 @@ LOCAL_CONTEXT_BACKUP_TABLES = (
99
99
  "local_context_queries",
100
100
  "local_index_dirs",
101
101
  )
102
- PROTECTED_BACKUP_TABLES = CRITICAL_BACKUP_TABLES
102
+ PROTECTED_BACKUP_TABLES = CRITICAL_BACKUP_TABLES + LOCAL_CONTEXT_BACKUP_TABLES
103
103
 
104
104
 
105
105
  def _backup_validation_tables(db_file: Path) -> tuple[str, ...]:
106
- if db_file.name == "nexo.db":
107
- return PROTECTED_BACKUP_TABLES
108
106
  if db_file.name == "local-context.db":
109
107
  return LOCAL_CONTEXT_BACKUP_TABLES
110
- return ()
108
+ return PROTECTED_BACKUP_TABLES
111
109
 
112
110
 
113
111
  CLASSIFIER_INSTALL_TIMEOUT_SECONDS = 1800
@@ -1447,6 +1445,7 @@ def _self_heal_if_wiped() -> dict | None:
1447
1445
  from db_guard import (
1448
1446
  CRITICAL_TABLES,
1449
1447
  HOURLY_BACKUP_MAX_AGE,
1448
+ LOCAL_CONTEXT_TABLES,
1450
1449
  MIN_REFERENCE_ROWS,
1451
1450
  PROTECTED_TABLES,
1452
1451
  db_looks_wiped,
@@ -1467,14 +1466,15 @@ def _self_heal_if_wiped() -> dict | None:
1467
1466
  return None
1468
1467
  if not db_looks_wiped(primary, PROTECTED_TABLES):
1469
1468
  return None
1469
+ recovery_tables = PROTECTED_TABLES + LOCAL_CONTEXT_TABLES
1470
1470
  reference = find_best_hourly_backup(
1471
1471
  paths.backups_dir(),
1472
1472
  max_age_seconds=HOURLY_BACKUP_MAX_AGE,
1473
- tables=PROTECTED_TABLES,
1473
+ tables=recovery_tables,
1474
1474
  ) or find_latest_hourly_backup(
1475
1475
  paths.backups_dir(),
1476
1476
  max_age_seconds=HOURLY_BACKUP_MAX_AGE,
1477
- tables=PROTECTED_TABLES,
1477
+ tables=recovery_tables,
1478
1478
  )
1479
1479
  if reference is None:
1480
1480
  _log("self-heal: nexo.db looks wiped but no usable hourly backup found — skipping.")
@@ -1483,7 +1483,7 @@ def _self_heal_if_wiped() -> dict | None:
1483
1483
  "reason": "no_usable_hourly_backup",
1484
1484
  "primary_db": str(primary),
1485
1485
  }
1486
- ref_counts = db_row_counts(reference, PROTECTED_TABLES)
1486
+ ref_counts = db_row_counts(reference, recovery_tables)
1487
1487
  ref_total = sum(v for v in ref_counts.values() if isinstance(v, int))
1488
1488
  if ref_total < MIN_REFERENCE_ROWS:
1489
1489
  _log(f"self-heal: reference backup {reference.name} has {ref_total} rows, below floor {MIN_REFERENCE_ROWS}")
@@ -1572,7 +1572,7 @@ def _self_heal_if_wiped() -> dict | None:
1572
1572
  "quiesce": quiesce_report,
1573
1573
  "resume": resume_report,
1574
1574
  }
1575
- valid, valid_err = validate_backup_matches_source(reference, primary, PROTECTED_TABLES)
1575
+ valid, valid_err = validate_backup_matches_source(reference, primary, recovery_tables)
1576
1576
  if not valid:
1577
1577
  _log(f"self-heal: post-restore validation failed: {valid_err}")
1578
1578
  resume_report = _resume_quiesced()
@@ -1586,7 +1586,7 @@ def _self_heal_if_wiped() -> dict | None:
1586
1586
  "resume": resume_report,
1587
1587
  }
1588
1588
 
1589
- final_counts = db_row_counts(primary, PROTECTED_TABLES)
1589
+ final_counts = db_row_counts(primary, recovery_tables)
1590
1590
  final_total = sum(v for v in final_counts.values() if isinstance(v, int))
1591
1591
  _log(f"self-heal: restored {final_total} critical rows from {reference.name}.")
1592
1592
  resume_report = _resume_quiesced()
@@ -3988,7 +3988,7 @@ def _auto_update_check_locked() -> dict:
3988
3988
 
3989
3989
  # Backfill runtime CLI modules for existing installs
3990
3990
  try:
3991
- for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "claude_cli.py", "agent_runner.py", "bootstrap_docs.py"):
3991
+ for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "claude_cli.py", "agent_runner.py", "bootstrap_docs.py", "mcp_required_tools.py"):
3992
3992
  src_file = SRC_DIR / fname
3993
3993
  dest_file = NEXO_HOME / fname
3994
3994
  if src_file.is_file() and (not dest_file.exists() or src_file.stat().st_mtime > dest_file.stat().st_mtime):
package/src/cli.py CHANGED
@@ -52,7 +52,17 @@ from pathlib import Path
52
52
 
53
53
  from runtime_home import export_resolved_nexo_home
54
54
  from runtime_versioning import build_mcp_status, clear_restart_required_marker
55
- from mcp_required_tools import BOOTSTRAP_REQUIRED_MCP_TOOLS, missing_required_tools
55
+ try:
56
+ from mcp_required_tools import BOOTSTRAP_REQUIRED_MCP_TOOLS, missing_required_tools
57
+ except ModuleNotFoundError as exc:
58
+ if getattr(exc, "name", "") != "mcp_required_tools":
59
+ raise
60
+ # Older installed runtimes can be missing modules added after their CLI was
61
+ # copied. Keep `nexo update` bootable so the sync can repair the runtime.
62
+ BOOTSTRAP_REQUIRED_MCP_TOOLS: tuple[str, ...] = ()
63
+
64
+ def missing_required_tools(_tool_names):
65
+ return []
56
66
 
57
67
  NEXO_HOME = export_resolved_nexo_home()
58
68
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
@@ -3389,7 +3399,7 @@ def main():
3389
3399
 
3390
3400
  local_context_query_p = local_context_sub.add_parser("query", help="Query local memory evidence")
3391
3401
  local_context_query_p.add_argument("query", help="Question or search phrase")
3392
- local_context_query_p.add_argument("--intent", default="answer", help="Intent label stored with the query audit row")
3402
+ local_context_query_p.add_argument("--intent", default="answer", help="Intent label included with the query result")
3393
3403
  local_context_query_p.add_argument("--limit", type=int, default=12, help="Maximum evidence rows")
3394
3404
  local_context_query_p.add_argument("--current-context", default="", help="Optional current conversation/task context")
3395
3405
  local_context_query_p.add_argument("--mode", choices=["compact", "full"], default="compact", help="Payload shape. Compact is safe for chat clients; full is for debugging.")
package/src/db_guard.py CHANGED
@@ -86,12 +86,17 @@ LOCAL_CONTEXT_TABLES: tuple[str, ...] = (
86
86
  "local_index_dirs",
87
87
  )
88
88
 
89
- # Tables protected inside the operational Brain DB. Local memory now lives in
90
- # runtime/memory/local-context.db and is validated with LOCAL_CONTEXT_TABLES
91
- # separately; mixing both here makes old local-index backups look like the
92
- # source of truth for nexo.db and can block every update.
89
+ # Tables protected inside the operational Brain DB. Keep this core-only:
90
+ # callers that need local memory checks must request LOCAL_CONTEXT_TABLES or
91
+ # RECOVERY_TABLES explicitly so normal Brain wipe detection is not skewed by
92
+ # split local-memory databases.
93
93
  PROTECTED_TABLES: tuple[str, ...] = CRITICAL_TABLES
94
94
 
95
+ # Recovery must still inspect legacy backups that carried local memory tables
96
+ # inside nexo.db. This wider set is safe for row counts and restore validation,
97
+ # but should not replace PROTECTED_TABLES in core wipe-diff callers.
98
+ RECOVERY_TABLES: tuple[str, ...] = CRITICAL_TABLES + LOCAL_CONTEXT_TABLES
99
+
95
100
  # A reference backup must contain at least this many rows (summed across
96
101
  # CRITICAL_TABLES) before we will treat it as "proof the user has real data".
97
102
  # Otherwise we cannot distinguish a fresh install from a wipe.
@@ -211,7 +216,7 @@ def _table_count(conn: sqlite3.Connection, table: str) -> int | None:
211
216
  return int(result[0]) if result is not None else 0
212
217
 
213
218
 
214
- def db_row_counts(path: str | Path, tables: tuple[str, ...] = PROTECTED_TABLES) -> dict[str, int | None]:
219
+ def db_row_counts(path: str | Path, tables: tuple[str, ...] = RECOVERY_TABLES) -> dict[str, int | None]:
215
220
  """Return {table: count} for a SQLite DB. Missing DB / missing tables map to None."""
216
221
  p = Path(path)
217
222
  counts: dict[str, int | None] = {t: None for t in tables}
@@ -253,11 +258,22 @@ def db_looks_wiped(
253
258
  size = p.stat().st_size
254
259
  except OSError:
255
260
  return False
261
+ counts = db_row_counts(p, tables)
256
262
  if size <= EMPTY_DB_SIZE_BYTES:
257
263
  # Small but not necessarily wiped — confirm via row counts.
258
- counts = db_row_counts(p, tables)
259
- return _all_tables_empty_or_missing(counts)
260
- counts = db_row_counts(p, tables)
264
+ return _counts_look_wiped(counts)
265
+ return _counts_look_wiped(counts)
266
+
267
+
268
+ def _counts_look_wiped(counts: dict[str, int | None]) -> bool:
269
+ """Treat Brain table loss as a wipe even if legacy local tables remain."""
270
+ critical_present = {
271
+ table: counts.get(table)
272
+ for table in CRITICAL_TABLES
273
+ if counts.get(table) is not None
274
+ }
275
+ if critical_present:
276
+ return _all_tables_empty_or_missing(critical_present)
261
277
  return _all_tables_empty_or_missing(counts)
262
278
 
263
279
 
@@ -44,6 +44,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
44
44
  from db_guard import (
45
45
  CRITICAL_TABLES,
46
46
  EMPTY_DB_SIZE_BYTES,
47
+ LOCAL_CONTEXT_TABLES,
47
48
  MIN_REFERENCE_ROWS,
48
49
  PROTECTED_TABLES,
49
50
  db_looks_wiped,
@@ -98,6 +99,47 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
98
99
  reference_counts = db_row_counts(reference, PROTECTED_TABLES) if reference else {}
99
100
  reference_rows = sum(v for v in reference_counts.values() if isinstance(v, int))
100
101
  protected_regression = diff_row_counts(db_path, reference, PROTECTED_TABLES) if reference else None
102
+ local_context_db = paths.memory_dir() / "local-context.db"
103
+ local_needs_reference = (
104
+ not local_context_db.is_file()
105
+ or db_looks_wiped(local_context_db, LOCAL_CONTEXT_TABLES)
106
+ )
107
+ local_reference = None
108
+ if local_needs_reference:
109
+ local_reference = (
110
+ find_best_hourly_backup(
111
+ paths.backups_dir(),
112
+ glob="local-context-*.db",
113
+ min_critical_rows=MIN_REFERENCE_ROWS,
114
+ tables=LOCAL_CONTEXT_TABLES,
115
+ )
116
+ or find_latest_hourly_backup(
117
+ paths.backups_dir(),
118
+ glob="local-context-*.db",
119
+ min_critical_rows=MIN_REFERENCE_ROWS,
120
+ tables=LOCAL_CONTEXT_TABLES,
121
+ )
122
+ or find_best_hourly_backup(
123
+ paths.backups_dir(),
124
+ glob="nexo-*.db",
125
+ min_critical_rows=MIN_REFERENCE_ROWS,
126
+ tables=LOCAL_CONTEXT_TABLES,
127
+ )
128
+ or find_latest_hourly_backup(
129
+ paths.backups_dir(),
130
+ glob="nexo-*.db",
131
+ min_critical_rows=MIN_REFERENCE_ROWS,
132
+ tables=LOCAL_CONTEXT_TABLES,
133
+ )
134
+ )
135
+ local_reference_counts = db_row_counts(local_reference, LOCAL_CONTEXT_TABLES) if local_reference else {}
136
+ local_reference_rows = sum(v for v in local_reference_counts.values() if isinstance(v, int))
137
+ local_regression = diff_row_counts(local_context_db, local_reference, LOCAL_CONTEXT_TABLES) if local_reference else None
138
+ recoverable_local_regression = bool(
139
+ local_reference
140
+ and local_regression
141
+ and local_regression.is_wipe()
142
+ )
101
143
  recoverable_regression = bool(
102
144
  reference
103
145
  and protected_regression
@@ -121,7 +163,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
121
163
  )
122
164
  )
123
165
 
124
- if quick_ok and not recoverable_wipe and not recoverable_regression:
166
+ if quick_ok and not recoverable_wipe and not recoverable_regression and not recoverable_local_regression:
125
167
  if looks_wiped and not reference:
126
168
  return DoctorCheck(
127
169
  id="boot.db_integrity",
@@ -150,6 +192,11 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
150
192
  evidence.append(f"Reference backup: {reference} ({reference_rows} protected rows)")
151
193
  if protected_regression:
152
194
  evidence.extend(protected_regression.summary_lines())
195
+ if local_reference:
196
+ evidence.append(f"Local memory DB: {local_context_db}")
197
+ evidence.append(f"Local memory reference: {local_reference} ({local_reference_rows} rows)")
198
+ if local_regression:
199
+ evidence.extend(["Local memory regression:", *local_regression.summary_lines()])
153
200
 
154
201
  if fix and recoverable_regression:
155
202
  report = restore_tables_from_backup(reference, db_path)
@@ -205,7 +252,49 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
205
252
  repair_plan=["Run nexo recover --force --yes, then restart Desktop"],
206
253
  )
207
254
 
208
- if recoverable_wipe or recoverable_regression:
255
+ if fix and recoverable_local_regression:
256
+ try:
257
+ local_context_db.parent.mkdir(parents=True, exist_ok=True)
258
+ if not local_context_db.exists():
259
+ sqlite3.connect(str(local_context_db)).close()
260
+ except Exception as exc:
261
+ return DoctorCheck(
262
+ id="boot.db_integrity",
263
+ tier="boot",
264
+ status="critical",
265
+ severity="error",
266
+ summary="Local memory sidecar repair failed",
267
+ evidence=evidence + [f"Cannot create local memory DB: {type(exc).__name__}: {exc}"],
268
+ repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
269
+ )
270
+ report = restore_tables_from_backup(local_reference, local_context_db, tables=LOCAL_CONTEXT_TABLES)
271
+ if report.get("ok"):
272
+ restored = {
273
+ table: payload
274
+ for table, payload in (report.get("tables") or {}).items()
275
+ if isinstance(payload, dict) and payload.get("status") == "restored"
276
+ }
277
+ restored_rows = sum(int(payload.get("after") or 0) for payload in restored.values())
278
+ return DoctorCheck(
279
+ id="boot.db_integrity",
280
+ tier="boot",
281
+ status="healthy",
282
+ severity="info",
283
+ summary=f"Local memory sidecar restored from backup ({restored_rows} rows)",
284
+ evidence=evidence + [f"Restored local-memory sidecar tables: {len(restored)}"],
285
+ fixed=True,
286
+ )
287
+ return DoctorCheck(
288
+ id="boot.db_integrity",
289
+ tier="boot",
290
+ status="critical",
291
+ severity="error",
292
+ summary="Local memory sidecar repair failed",
293
+ evidence=evidence + [f"Restore errors: {report.get('errors') or []}"],
294
+ repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
295
+ )
296
+
297
+ if recoverable_wipe or recoverable_regression or recoverable_local_regression:
209
298
  return DoctorCheck(
210
299
  id="boot.db_integrity",
211
300
  tier="boot",
@@ -4,15 +4,17 @@ import json
4
4
  import os
5
5
  import re
6
6
  import shutil
7
+ import sqlite3
7
8
  import stat
8
9
  import hashlib
9
10
  import subprocess
10
11
  import sys
12
+ import time
11
13
  from pathlib import Path
12
14
  from typing import Any
13
15
 
14
16
  from . import embeddings
15
- from .db import ensure_local_context_db, get_local_context_db
17
+ from .db import LOCAL_CONTEXT_TABLES, close_local_context_db, connect_local_context_db_readonly, ensure_local_context_db, get_local_context_db
16
18
  from .extractors import chunk_text, contains_secret, entities, extract_text, summarize
17
19
  from .logging import log_event, tail
18
20
  from .privacy import classify_path, is_local_email_tree, is_queryable_path, should_extract, should_skip_file, should_skip_tree
@@ -32,6 +34,8 @@ DEFAULT_SYSTEM_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_SYSTEM_ROOT_DEP
32
34
  DEFAULT_CONTEXT_MAX_CHARS = int(os.environ.get("NEXO_LOCAL_CONTEXT_MAX_CHARS", "20000") or "20000")
33
35
  DEFAULT_ROUTER_MAX_CHARS = int(os.environ.get("NEXO_LOCAL_CONTEXT_ROUTER_MAX_CHARS", "6000") or "6000")
34
36
  DEFAULT_MAX_JOB_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_INDEX_MAX_JOB_ATTEMPTS", "3") or "3")
37
+ DEFAULT_SQLITE_BUSY_RETRY_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_ATTEMPTS", "5") or "5")
38
+ DEFAULT_SQLITE_BUSY_RETRY_DELAY_SECONDS = float(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_DELAY_SECONDS", "0.35") or "0.35")
35
39
  INITIAL_INDEX_COMPLETE_KEY = "initial_index_complete"
36
40
  INITIAL_INDEX_STARTED_AT_KEY = "initial_index_started_at"
37
41
  PERFORMANCE_PROFILE_KEY = "performance_profile"
@@ -40,7 +44,7 @@ VALID_CONTEXT_MODES = {"compact", "full"}
40
44
  PERFORMANCE_PROFILES: dict[str, dict[str, Any]] = {
41
45
  "low": {
42
46
  "profile": "low",
43
- "label": "Bajo",
47
+ "label_key": "local_context.performance.low",
44
48
  "scan_limit": 250,
45
49
  "process_limit": 50,
46
50
  "live_asset_limit": 500,
@@ -51,7 +55,7 @@ PERFORMANCE_PROFILES: dict[str, dict[str, Any]] = {
51
55
  },
52
56
  "medium": {
53
57
  "profile": "medium",
54
- "label": "Medio",
58
+ "label_key": "local_context.performance.medium",
55
59
  "scan_limit": 1000,
56
60
  "process_limit": 200,
57
61
  "live_asset_limit": DEFAULT_LIVE_ASSET_LIMIT,
@@ -62,7 +66,7 @@ PERFORMANCE_PROFILES: dict[str, dict[str, Any]] = {
62
66
  },
63
67
  "high": {
64
68
  "profile": "high",
65
- "label": "Alto",
69
+ "label_key": "local_context.performance.high",
66
70
  "scan_limit": 3000,
67
71
  "process_limit": 600,
68
72
  "live_asset_limit": 5000,
@@ -73,7 +77,7 @@ PERFORMANCE_PROFILES: dict[str, dict[str, Any]] = {
73
77
  },
74
78
  "extreme": {
75
79
  "profile": "extreme",
76
- "label": "Extremo",
80
+ "label_key": "local_context.performance.extreme",
77
81
  "scan_limit": 8000,
78
82
  "process_limit": 1500,
79
83
  "live_asset_limit": 10000,
@@ -94,6 +98,40 @@ def _conn():
94
98
  return get_local_context_db()
95
99
 
96
100
 
101
+ def _read_conn():
102
+ conn = connect_local_context_db_readonly(timeout_ms=1200)
103
+ _validate_status_schema(conn)
104
+ return conn
105
+
106
+
107
+ def _close_read_conn(conn) -> None:
108
+ try:
109
+ conn.close()
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ def _sqlite_is_busy(exc: BaseException) -> bool:
115
+ return isinstance(exc, sqlite3.OperationalError) and "locked" in str(exc).lower()
116
+
117
+
118
+ def _with_sqlite_busy_retry(callback, *, attempts: int | None = None):
119
+ max_attempts = max(1, int(attempts or DEFAULT_SQLITE_BUSY_RETRY_ATTEMPTS))
120
+ last_exc = None
121
+ for attempt in range(max_attempts):
122
+ try:
123
+ return callback()
124
+ except sqlite3.OperationalError as exc:
125
+ if not _sqlite_is_busy(exc) or attempt >= max_attempts - 1:
126
+ raise
127
+ last_exc = exc
128
+ close_local_context_db()
129
+ time.sleep(DEFAULT_SQLITE_BUSY_RETRY_DELAY_SECONDS * (attempt + 1))
130
+ if last_exc:
131
+ raise last_exc
132
+ return None
133
+
134
+
97
135
  def add_root(path: str, *, mode: str = "normal", depth: int | None = None) -> dict:
98
136
  conn = _conn()
99
137
  root_path = norm_path(path)
@@ -136,8 +174,18 @@ def remove_root(path: str) -> dict:
136
174
  return {"ok": True, "root_path": root_path, "cleanup": cleanup}
137
175
 
138
176
 
139
- def list_roots() -> list[dict]:
140
- conn = _conn()
177
+ def list_roots(*, readonly: bool = True) -> list[dict]:
178
+ if not readonly:
179
+ conn = _conn()
180
+ return _list_roots_conn(conn)
181
+ conn = _read_conn()
182
+ try:
183
+ return _list_roots_conn(conn)
184
+ finally:
185
+ _close_read_conn(conn)
186
+
187
+
188
+ def _list_roots_conn(conn) -> list[dict]:
141
189
  rows = conn.execute("SELECT * FROM local_index_roots WHERE status != 'removed' ORDER BY root_path").fetchall()
142
190
  return [dict(row) for row in rows]
143
191
 
@@ -265,7 +313,7 @@ def default_root_specs() -> list[tuple[str, int]]:
265
313
 
266
314
 
267
315
  def ensure_default_roots() -> dict:
268
- existing = {row["root_path"]: row for row in list_roots()}
316
+ existing = {row["root_path"]: row for row in list_roots(readonly=False)}
269
317
  created = []
270
318
  updated = []
271
319
  for root, depth in default_root_specs():
@@ -285,7 +333,7 @@ def ensure_default_roots() -> dict:
285
333
  updated.append({"root_path": existing_row["root_path"], "depth": depth})
286
334
  continue
287
335
  created.append(add_root(str(candidate), mode="normal", depth=depth))
288
- return {"ok": True, "created": len(created), "updated": len(updated), "roots": list_roots()}
336
+ return {"ok": True, "created": len(created), "updated": len(updated), "roots": list_roots(readonly=False)}
289
337
 
290
338
 
291
339
  def _should_skip_mounted_root(candidate: Path) -> bool:
@@ -557,8 +605,18 @@ def remove_exclusion(path: str) -> dict:
557
605
  return {"ok": True, "path": excluded_path}
558
606
 
559
607
 
560
- def list_exclusions() -> list[dict]:
561
- conn = _conn()
608
+ def list_exclusions(*, readonly: bool = True) -> list[dict]:
609
+ if not readonly:
610
+ conn = _conn()
611
+ return _list_exclusions_conn(conn)
612
+ conn = _read_conn()
613
+ try:
614
+ return _list_exclusions_conn(conn)
615
+ finally:
616
+ _close_read_conn(conn)
617
+
618
+
619
+ def _list_exclusions_conn(conn) -> list[dict]:
562
620
  rows = conn.execute("SELECT * FROM local_index_exclusions ORDER BY path").fetchall()
563
621
  return [dict(row) for row in rows]
564
622
 
@@ -575,9 +633,12 @@ def _set_state_conn(conn, key: str, value: str) -> None:
575
633
 
576
634
 
577
635
  def _set_state(key: str, value: str) -> None:
578
- conn = _conn()
579
- _set_state_conn(conn, key, value)
580
- conn.commit()
636
+ def write_state() -> None:
637
+ conn = _conn()
638
+ _set_state_conn(conn, key, value)
639
+ conn.commit()
640
+
641
+ _with_sqlite_busy_retry(write_state)
581
642
 
582
643
 
583
644
  def _get_state_conn(conn, key: str, default: str = "") -> str:
@@ -700,6 +761,15 @@ def _ensure_initial_index_started_at(conn) -> float:
700
761
  return value
701
762
 
702
763
 
764
+ def _initial_index_started_at_readonly(conn) -> float:
765
+ raw = _get_state_conn(conn, INITIAL_INDEX_STARTED_AT_KEY, "")
766
+ try:
767
+ value = float(raw or 0)
768
+ except Exception:
769
+ value = 0.0
770
+ return value if value > 0 else (_earliest_index_activity(conn) or 0.0)
771
+
772
+
703
773
  def _active_job_count(conn) -> int:
704
774
  row = conn.execute(
705
775
  """
@@ -711,20 +781,20 @@ def _active_job_count(conn) -> int:
711
781
  return int(row["total"] or 0)
712
782
 
713
783
 
714
- def _refresh_initial_index_complete(conn, initial_scan: dict | None = None, active_jobs: int | None = None) -> bool:
784
+ def _refresh_initial_index_complete(conn, initial_scan: dict | None = None, active_jobs: int | None = None, *, readonly: bool = False) -> bool:
715
785
  if _initial_index_complete(conn):
716
786
  return True
717
787
  scan_state = initial_scan if initial_scan is not None else _initial_scan_status(conn)
718
788
  remaining = _active_job_count(conn) if active_jobs is None else int(active_jobs or 0)
719
789
  complete = bool(scan_state.get("complete")) and remaining == 0
720
- if complete:
790
+ if complete and not readonly:
721
791
  _set_initial_index_complete(conn, True)
722
792
  conn.commit()
723
793
  return complete
724
794
 
725
795
 
726
796
  def _initial_scan_status(conn, roots: list[dict] | None = None) -> dict:
727
- rows = roots if roots is not None else list_roots()
797
+ rows = roots if roots is not None else _list_roots_conn(conn)
728
798
  tracked = _effective_scan_roots([dict(row) for row in rows if str(row.get("status") or "active") not in {"removed", "offline"}])
729
799
  pending = [row for row in tracked if not _root_initial_scan_complete(conn, row)]
730
800
  checkpoints = conn.execute(
@@ -753,7 +823,12 @@ def resume() -> dict:
753
823
 
754
824
 
755
825
  def _is_paused() -> bool:
756
- return _get_state("paused", "0") == "1"
826
+ conn = _conn()
827
+ return _is_paused_conn(conn)
828
+
829
+
830
+ def _is_paused_conn(conn) -> bool:
831
+ return _get_state_conn(conn, "paused", "0") == "1"
757
832
 
758
833
 
759
834
  def _allow_explicit_blocked_root(path: str) -> bool:
@@ -1465,7 +1540,7 @@ def reconcile_live_changes(
1465
1540
  conn = _conn()
1466
1541
  if _is_paused():
1467
1542
  return {"ok": True, "paused": True, "assets": {}, "dirs": {}}
1468
- exclusions = [row["path"] for row in list_exclusions()]
1543
+ exclusions = [row["path"] for row in list_exclusions(readonly=False)]
1469
1544
  asset_stats = _reconcile_known_assets(conn, exclusions, limit=int(asset_limit or 0))
1470
1545
  dir_stats = _reconcile_known_dirs(conn, exclusions, dir_limit=int(dir_limit or 0), file_limit=int(file_limit or 0))
1471
1546
  conn.commit()
@@ -1498,8 +1573,8 @@ def scan_once(*, limit: int | None = None) -> dict:
1498
1573
  log_event("info", "scan_skipped_paused", "Local memory scan skipped because indexing is paused")
1499
1574
  return {"ok": True, "paused": True, "roots": 0, "seen": 0, "changed": 0, "errors": 0, "partial": False}
1500
1575
  started = now()
1501
- roots = _effective_scan_roots(list_roots())
1502
- exclusions = [row["path"] for row in list_exclusions()]
1576
+ roots = _effective_scan_roots(list_roots(readonly=False))
1577
+ exclusions = [row["path"] for row in list_exclusions(readonly=False)]
1503
1578
  totals = {"roots": len(roots), "seen": 0, "changed": 0, "errors": 0, "partial": False}
1504
1579
  log_event("info", "scan_started", "Local memory scan started", roots=len(roots))
1505
1580
  for root in roots:
@@ -1697,6 +1772,7 @@ def process_jobs(*, limit: int = 100) -> dict:
1697
1772
  "UPDATE local_index_jobs SET status='running', claimed_by='local-process', lease_expires_at=?, updated_at=? WHERE job_id=?",
1698
1773
  (now() + 300, now(), job_id),
1699
1774
  )
1775
+ conn.commit()
1700
1776
  try:
1701
1777
  if row["asset_status"] != "active":
1702
1778
  raise FileNotFoundError(row["path"])
@@ -1706,6 +1782,7 @@ def process_jobs(*, limit: int = 100) -> dict:
1706
1782
  (now(), job_id),
1707
1783
  )
1708
1784
  processed += 1
1785
+ conn.commit()
1709
1786
  continue
1710
1787
  if job_type == "light_extraction":
1711
1788
  text, metadata = extract_text(Path(row["path"]))
@@ -1717,6 +1794,7 @@ def process_jobs(*, limit: int = 100) -> dict:
1717
1794
  (now(), job_id),
1718
1795
  )
1719
1796
  processed += 1
1797
+ conn.commit()
1720
1798
  continue
1721
1799
  summary = summarize(text)
1722
1800
  conn.execute(
@@ -1739,6 +1817,7 @@ def process_jobs(*, limit: int = 100) -> dict:
1739
1817
  (now(), job_id),
1740
1818
  )
1741
1819
  processed += 1
1820
+ conn.commit()
1742
1821
  except Exception as exc:
1743
1822
  failed += 1
1744
1823
  attempts = int(row["attempt_count"] or 0) + 1
@@ -1761,6 +1840,7 @@ def process_jobs(*, limit: int = 100) -> dict:
1761
1840
  technical_detail=str(exc),
1762
1841
  retryable=not terminal,
1763
1842
  )
1843
+ conn.commit()
1764
1844
  conn.commit()
1765
1845
  if processed or failed:
1766
1846
  log_event("info", "jobs_processed", "Local memory jobs processed", processed=processed, failed=failed)
@@ -1793,7 +1873,7 @@ def run_once(
1793
1873
  effective_live_dir_limit = int(live_dir_limit if live_dir_limit is not None else config["live_dir_limit"])
1794
1874
  effective_live_file_limit = int(live_file_limit if live_file_limit is not None else config["live_file_limit"])
1795
1875
  conn = _conn()
1796
- initial_before = _initial_scan_status(conn, list_roots())
1876
+ initial_before = _initial_scan_status(conn, list_roots(readonly=False))
1797
1877
  initial_index_before = _refresh_initial_index_complete(conn, initial_before)
1798
1878
  if initial_index_before:
1799
1879
  live_result = reconcile_live_changes(
@@ -1812,7 +1892,7 @@ def run_once(
1812
1892
  scan_result = scan_once(limit=effective_scan_limit)
1813
1893
  job_result = process_jobs(limit=effective_process_limit)
1814
1894
  conn_after = _conn()
1815
- initial_after = _initial_scan_status(conn_after, list_roots())
1895
+ initial_after = _initial_scan_status(conn_after, list_roots(readonly=False))
1816
1896
  active_after = _active_job_count(conn_after)
1817
1897
  initial_index_after = _refresh_initial_index_complete(conn_after, initial_after, active_after)
1818
1898
  return {
@@ -1854,8 +1934,10 @@ def _problem_rows(conn) -> list[dict]:
1854
1934
  ).fetchall()
1855
1935
  problems = [
1856
1936
  {
1857
- "user_message": row["user_message"],
1858
- "recommended_action": "NEXO lo volvera a intentar mas tarde" if row["retryable"] else "Revisa permisos o archivo",
1937
+ "user_message": "",
1938
+ "message_key": "local_context.problem.file_read_failed",
1939
+ "recommended_action": "",
1940
+ "recommended_action_key": "local_context.retry_later" if row["retryable"] else "local_context.review_permissions_or_file",
1859
1941
  "technical_detail": row["technical_detail"],
1860
1942
  "support_code": row["error_code"],
1861
1943
  "severity": "warning",
@@ -1882,8 +1964,10 @@ def _problem_rows(conn) -> list[dict]:
1882
1964
  ).fetchall()
1883
1965
  problems.extend(
1884
1966
  {
1885
- "user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
1886
- "recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
1967
+ "user_message": "",
1968
+ "message_key": "local_context.problem.service_temporary",
1969
+ "recommended_action": "",
1970
+ "recommended_action_key": "local_context.retry_automatic",
1887
1971
  "technical_detail": f"{row['event']}: {row['message']} {row['metadata_json']}",
1888
1972
  "support_code": row["event"],
1889
1973
  "severity": "warning" if row["level"] == "warn" else "error",
@@ -2084,13 +2168,13 @@ def _service_cycle_observation(conn) -> dict:
2084
2168
  return observation
2085
2169
 
2086
2170
 
2087
- def _index_timing(conn, *, done: int, active_jobs: int, percent: int) -> dict:
2088
- first_seen = _ensure_initial_index_started_at(conn)
2171
+ def _index_timing(conn, *, done: int, active_jobs: int, percent: int, readonly: bool = False) -> dict:
2172
+ first_seen = _initial_index_started_at_readonly(conn) if readonly else _ensure_initial_index_started_at(conn)
2089
2173
  elapsed_seconds = max(0, int(now() - float(first_seen))) if first_seen else 0
2090
2174
  eta_seconds = None
2091
2175
  if elapsed_seconds > 0 and done > 0 and active_jobs > 0 and 0 < percent < 100:
2092
2176
  eta_seconds = max(0, int((elapsed_seconds / max(done, 1)) * active_jobs))
2093
- return {"elapsed_seconds": elapsed_seconds, "eta_seconds": eta_seconds}
2177
+ return {"started_at": first_seen, "elapsed_seconds": elapsed_seconds, "eta_seconds": eta_seconds}
2094
2178
 
2095
2179
 
2096
2180
  def _service_scheduler_has_error(service: dict) -> bool:
@@ -2107,38 +2191,118 @@ def _service_problem(service: dict) -> dict | None:
2107
2191
  if not service.get("installed"):
2108
2192
  return {
2109
2193
  "support_code": "local_index_service_not_installed",
2110
- "user_message": "La memoria local aun no tiene activo el servicio en segundo plano",
2111
- "recommended_action": "Reabre NEXO Desktop o actualiza a la ultima version para instalarlo automaticamente.",
2194
+ "user_message": "",
2195
+ "message_key": "local_context.problem.service_not_installed",
2196
+ "recommended_action": "",
2197
+ "recommended_action_key": "local_context.reopen_or_update_desktop",
2112
2198
  "technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
2113
2199
  }
2114
2200
  if not service.get("running"):
2115
2201
  return {
2116
2202
  "support_code": "local_index_service_not_running",
2117
- "user_message": "La memoria local no se esta actualizando en segundo plano",
2118
- "recommended_action": "NEXO intentara recuperarlo automaticamente. Si se repite, abre soporte y diagnostico.",
2203
+ "user_message": "",
2204
+ "message_key": "local_context.problem.service_not_running",
2205
+ "recommended_action": "",
2206
+ "recommended_action_key": "local_context.retry_automatic",
2119
2207
  "technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
2120
2208
  }
2121
2209
  if _service_scheduler_has_error(service):
2122
2210
  code = service.get("last_exit_code") or service.get("last_task_result") or ""
2123
2211
  return {
2124
2212
  "support_code": "local_index_service_last_run_failed",
2125
- "user_message": "La ultima comprobacion de memoria local no termino correctamente",
2126
- "recommended_action": "NEXO lo volvera a intentar automaticamente.",
2213
+ "user_message": "",
2214
+ "message_key": "local_context.problem.service_last_run_failed",
2215
+ "recommended_action": "",
2216
+ "recommended_action_key": "local_context.retry_automatic",
2127
2217
  "technical_detail": f"last_result={code}",
2128
2218
  }
2129
2219
  if not service.get("healthy", True):
2130
2220
  return {
2131
2221
  "support_code": service.get("last_error_code") or "local_index_service_failed",
2132
- "user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
2133
- "recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
2222
+ "user_message": "",
2223
+ "message_key": "local_context.problem.service_temporary",
2224
+ "recommended_action": "",
2225
+ "recommended_action_key": "local_context.retry_automatic",
2134
2226
  "technical_detail": service.get("last_error_detail") or "",
2135
2227
  }
2136
2228
  return None
2137
2229
 
2138
2230
 
2231
+ def _status_read_error(exc: Exception, *, code: str = "local_context_status_unavailable") -> dict:
2232
+ service = _local_index_service_status()
2233
+ service_problem = _service_problem(service)
2234
+ service["healthy"] = service_problem is None
2235
+ service["state"] = "attention" if service_problem else "unavailable"
2236
+ problems = []
2237
+ if service_problem:
2238
+ problems.append({
2239
+ "user_message": service_problem["user_message"],
2240
+ "message_key": service_problem.get("message_key", ""),
2241
+ "recommended_action": service_problem["recommended_action"],
2242
+ "recommended_action_key": service_problem.get("recommended_action_key", ""),
2243
+ "technical_detail": service_problem["technical_detail"],
2244
+ "support_code": service_problem["support_code"],
2245
+ "severity": "warning",
2246
+ "retryable": True,
2247
+ "path": "",
2248
+ "phase": "service",
2249
+ "created_at": now(),
2250
+ })
2251
+ problems.append({
2252
+ "user_message": "",
2253
+ "message_key": "local_context.status_unavailable",
2254
+ "recommended_action": "",
2255
+ "recommended_action_key": "local_context.retry_automatic",
2256
+ "technical_detail": str(exc),
2257
+ "support_code": code,
2258
+ "severity": "warning",
2259
+ "retryable": True,
2260
+ "path": "",
2261
+ "phase": "status",
2262
+ "created_at": now(),
2263
+ })
2264
+ return {
2265
+ "ok": False,
2266
+ "error": code,
2267
+ "retryable": True,
2268
+ "global": None,
2269
+ "service": service,
2270
+ "problems": problems,
2271
+ }
2272
+
2273
+
2274
+ def _status_db_error_code(exc: Exception) -> str:
2275
+ text = str(exc).lower()
2276
+ if "locked" in text or "busy" in text:
2277
+ return "local_context_db_busy"
2278
+ if "no such table" in text or "no such column" in text or "schema missing" in text or "missing tables" in text:
2279
+ return "local_context_db_schema_missing"
2280
+ if "file is not a database" in text or "database disk image is malformed" in text:
2281
+ return "local_context_db_invalid"
2282
+ return "local_context_db_unreadable"
2283
+
2284
+
2139
2285
  def status() -> dict:
2140
- conn = _conn()
2141
- paused = _is_paused()
2286
+ try:
2287
+ conn = connect_local_context_db_readonly(timeout_ms=1200)
2288
+ except FileNotFoundError as exc:
2289
+ return _status_read_error(exc, code="local_context_db_missing")
2290
+ except sqlite3.DatabaseError as exc:
2291
+ return _status_read_error(exc, code=_status_db_error_code(exc))
2292
+ try:
2293
+ return _status_from_conn(conn, readonly=True)
2294
+ except sqlite3.DatabaseError as exc:
2295
+ return _status_read_error(exc, code=_status_db_error_code(exc))
2296
+ finally:
2297
+ try:
2298
+ conn.close()
2299
+ except Exception:
2300
+ pass
2301
+
2302
+
2303
+ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
2304
+ _validate_status_schema(conn)
2305
+ paused = _is_paused_conn(conn)
2142
2306
  assets = conn.execute(
2143
2307
  """
2144
2308
  SELECT COUNT(*) AS total, SUM(CASE WHEN a.status='active' THEN 1 ELSE 0 END) AS active
@@ -2166,10 +2330,10 @@ def status() -> dict:
2166
2330
  active_jobs = pending + running_jobs + failed_jobs
2167
2331
  total_jobs = active_jobs + done
2168
2332
  percent = 100 if total_jobs == 0 else int((done / max(total_jobs, 1)) * 100)
2169
- timing = _index_timing(conn, done=done, active_jobs=active_jobs, percent=percent)
2170
- roots = list_roots()
2333
+ timing = _index_timing(conn, done=done, active_jobs=active_jobs, percent=percent, readonly=readonly)
2334
+ roots = _list_roots_conn(conn)
2171
2335
  initial_scan = _initial_scan_status(conn, roots)
2172
- initial_index_complete = _refresh_initial_index_complete(conn, initial_scan, active_jobs)
2336
+ initial_index_complete = _refresh_initial_index_complete(conn, initial_scan, active_jobs, readonly=readonly)
2173
2337
  volumes = []
2174
2338
  by_volume = conn.execute(
2175
2339
  """
@@ -2194,7 +2358,9 @@ def status() -> dict:
2194
2358
  if problem:
2195
2359
  problems.insert(0, {
2196
2360
  "user_message": problem["user_message"],
2361
+ "message_key": problem.get("message_key", ""),
2197
2362
  "recommended_action": problem["recommended_action"],
2363
+ "recommended_action_key": problem.get("recommended_action_key", ""),
2198
2364
  "technical_detail": problem["technical_detail"],
2199
2365
  "support_code": problem["support_code"],
2200
2366
  "severity": "warning",
@@ -2213,6 +2379,9 @@ def status() -> dict:
2213
2379
  phase = "idle"
2214
2380
  else:
2215
2381
  phase = "updating_changes"
2382
+ index_started_at = _get_state_conn(conn, INITIAL_INDEX_STARTED_AT_KEY, "")
2383
+ if not index_started_at and timing["started_at"]:
2384
+ index_started_at = str(float(timing["started_at"]))
2216
2385
  return {
2217
2386
  "ok": True,
2218
2387
  "service": service,
@@ -2227,7 +2396,7 @@ def status() -> dict:
2227
2396
  "jobs_failed": failed_jobs,
2228
2397
  "elapsed_seconds": timing["elapsed_seconds"],
2229
2398
  "eta_seconds": timing["eta_seconds"],
2230
- "index_started_at": _get_state_conn(conn, INITIAL_INDEX_STARTED_AT_KEY, ""),
2399
+ "index_started_at": index_started_at,
2231
2400
  "initial_scan_complete": bool(initial_index_complete),
2232
2401
  "initial_discovery_complete": bool(initial_scan["complete"]),
2233
2402
  "initial_index_complete": bool(initial_index_complete),
@@ -2239,7 +2408,7 @@ def status() -> dict:
2239
2408
  "initial_index_complete": bool(initial_index_complete),
2240
2409
  "volumes": volumes,
2241
2410
  "roots": roots,
2242
- "exclusions": list_exclusions(),
2411
+ "exclusions": _list_exclusions_conn(conn),
2243
2412
  "problems": problems,
2244
2413
  "permissions": [],
2245
2414
  "models": model_status()["models"],
@@ -2247,6 +2416,18 @@ def status() -> dict:
2247
2416
  }
2248
2417
 
2249
2418
 
2419
+ def _validate_status_schema(conn) -> None:
2420
+ placeholders = ",".join("?" for _ in LOCAL_CONTEXT_TABLES)
2421
+ rows = conn.execute(
2422
+ f"SELECT name FROM sqlite_master WHERE type='table' AND name IN ({placeholders})",
2423
+ tuple(LOCAL_CONTEXT_TABLES),
2424
+ ).fetchall()
2425
+ found = {str(row["name"] if isinstance(row, sqlite3.Row) else row[0]) for row in rows}
2426
+ missing = [table for table in LOCAL_CONTEXT_TABLES if table not in found]
2427
+ if missing:
2428
+ raise sqlite3.OperationalError("local context schema missing tables: " + ", ".join(missing[:8]))
2429
+
2430
+
2250
2431
  def diagnostics_tail(limit: int = 100) -> dict:
2251
2432
  return {"ok": True, "logs": tail(limit)}
2252
2433
 
@@ -2768,8 +2949,46 @@ def context_query(
2768
2949
  include_entities: bool = True,
2769
2950
  include_relations: bool = True,
2770
2951
  snippet_chars: int = 1200,
2952
+ readonly: bool = True,
2953
+ record_query: bool = False,
2954
+ ) -> dict:
2955
+ conn = _read_conn() if readonly else _conn()
2956
+ close_conn = bool(readonly)
2957
+ try:
2958
+ return _context_query_conn(
2959
+ conn,
2960
+ query,
2961
+ intent=intent,
2962
+ limit=limit,
2963
+ evidence_required=evidence_required,
2964
+ current_context=current_context,
2965
+ mode=mode,
2966
+ max_chars=max_chars,
2967
+ include_entities=include_entities,
2968
+ include_relations=include_relations,
2969
+ snippet_chars=snippet_chars,
2970
+ record_query=bool(record_query and not readonly),
2971
+ )
2972
+ finally:
2973
+ if close_conn:
2974
+ _close_read_conn(conn)
2975
+
2976
+
2977
+ def _context_query_conn(
2978
+ conn,
2979
+ query: str,
2980
+ *,
2981
+ intent: str,
2982
+ limit: int,
2983
+ evidence_required: bool,
2984
+ current_context: str,
2985
+ mode: str,
2986
+ max_chars: int,
2987
+ include_entities: bool,
2988
+ include_relations: bool,
2989
+ snippet_chars: int,
2990
+ record_query: bool,
2771
2991
  ) -> dict:
2772
- conn = _conn()
2773
2992
  clean_query = str(query or "").strip()
2774
2993
  normalized_mode, mode_warnings = _normalize_context_mode(mode)
2775
2994
  context_tail = _compact_text(current_context or "", max_chars=1000)
@@ -2843,21 +3062,22 @@ def context_query(
2843
3062
  summary = ""
2844
3063
  if assets:
2845
3064
  summary = f"Found {len(assets)} local asset(s) related to '{clean_query}'."
2846
- conn.execute(
2847
- """
2848
- INSERT INTO local_context_queries(query_hash, intent, result_count, confidence, warnings_json, created_at)
2849
- VALUES (?, ?, ?, ?, ?, ?)
2850
- """,
2851
- (
2852
- hashlib.sha256(clean_query.encode("utf-8", errors="ignore")).hexdigest(),
2853
- intent,
2854
- len(assets),
2855
- 0.75 if evidence_refs else 0.0,
2856
- json_dumps(warnings),
2857
- now(),
2858
- ),
2859
- )
2860
- conn.commit()
3065
+ if record_query:
3066
+ conn.execute(
3067
+ """
3068
+ INSERT INTO local_context_queries(query_hash, intent, result_count, confidence, warnings_json, created_at)
3069
+ VALUES (?, ?, ?, ?, ?, ?)
3070
+ """,
3071
+ (
3072
+ hashlib.sha256(clean_query.encode("utf-8", errors="ignore")).hexdigest(),
3073
+ intent,
3074
+ len(assets),
3075
+ 0.75 if evidence_refs else 0.0,
3076
+ json_dumps(warnings),
3077
+ now(),
3078
+ ),
3079
+ )
3080
+ conn.commit()
2861
3081
  payload = {
2862
3082
  "ok": True,
2863
3083
  "query": clean_query,
@@ -2881,26 +3101,34 @@ def context_query(
2881
3101
  )
2882
3102
 
2883
3103
 
2884
- def get_asset(asset_id: str) -> dict:
2885
- conn = _conn()
2886
- row = conn.execute("SELECT * FROM local_assets WHERE asset_id=?", (asset_id,)).fetchone()
2887
- if not row:
2888
- return {"ok": False, "error": "asset_not_found"}
2889
- return {"ok": True, "asset": dict(row)}
3104
+ def get_asset(asset_id: str, *, readonly: bool = True) -> dict:
3105
+ conn = _read_conn() if readonly else _conn()
3106
+ try:
3107
+ row = conn.execute("SELECT * FROM local_assets WHERE asset_id=?", (asset_id,)).fetchone()
3108
+ if not row:
3109
+ return {"ok": False, "error": "asset_not_found"}
3110
+ return {"ok": True, "asset": dict(row)}
3111
+ finally:
3112
+ if readonly:
3113
+ _close_read_conn(conn)
2890
3114
 
2891
3115
 
2892
- def get_neighbors(asset_id: str, *, limit: int = 30) -> dict:
2893
- conn = _conn()
2894
- rows = conn.execute(
2895
- """
2896
- SELECT * FROM local_relations
2897
- WHERE source_asset_id=? AND active=1
2898
- ORDER BY confidence DESC
2899
- LIMIT ?
2900
- """,
2901
- (asset_id, int(limit)),
2902
- ).fetchall()
2903
- return {"ok": True, "relations": [dict(row) for row in rows]}
3116
+ def get_neighbors(asset_id: str, *, limit: int = 30, readonly: bool = True) -> dict:
3117
+ conn = _read_conn() if readonly else _conn()
3118
+ try:
3119
+ rows = conn.execute(
3120
+ """
3121
+ SELECT * FROM local_relations
3122
+ WHERE source_asset_id=? AND active=1
3123
+ ORDER BY confidence DESC
3124
+ LIMIT ?
3125
+ """,
3126
+ (asset_id, int(limit)),
3127
+ ).fetchall()
3128
+ return {"ok": True, "relations": [dict(row) for row in rows]}
3129
+ finally:
3130
+ if readonly:
3131
+ _close_read_conn(conn)
2904
3132
 
2905
3133
 
2906
3134
  def purge_asset(asset_id: str) -> dict:
@@ -5,6 +5,7 @@ import sqlite3
5
5
  import time
6
6
  from pathlib import Path
7
7
  from typing import Iterable
8
+ from urllib.parse import quote
8
9
 
9
10
  import paths
10
11
  from db._schema import _m63_local_context_layer, _m64_local_context_live_dirs
@@ -79,6 +80,23 @@ def _connect(db_path: Path) -> sqlite3.Connection:
79
80
  return conn
80
81
 
81
82
 
83
+ def connect_local_context_db_readonly(*, timeout_ms: int = 1200) -> sqlite3.Connection:
84
+ db_path = local_context_db_path()
85
+ if not db_path.is_file():
86
+ raise FileNotFoundError(str(db_path))
87
+ timeout = max(float(timeout_ms) / 1000.0, 0.1)
88
+ uri_path = quote(db_path.resolve().as_posix(), safe="/:")
89
+ uri_params = "mode=ro"
90
+ if not db_path.with_name(db_path.name + "-wal").exists() and not db_path.with_name(db_path.name + "-shm").exists():
91
+ uri_params += "&immutable=1"
92
+ uri = f"file:{uri_path}?{uri_params}"
93
+ conn = sqlite3.connect(uri, uri=True, timeout=timeout, check_same_thread=False)
94
+ conn.row_factory = sqlite3.Row
95
+ conn.execute(f"PRAGMA busy_timeout={max(100, int(timeout_ms))}")
96
+ conn.execute("PRAGMA query_only=ON")
97
+ return conn
98
+
99
+
82
100
  def _ensure_schema(conn: sqlite3.Connection) -> None:
83
101
  _m63_local_context_layer(conn)
84
102
  _m64_local_context_live_dirs(conn)
@@ -35,6 +35,7 @@ from db_guard import (
35
35
  CRITICAL_TABLES,
36
36
  EMPTY_DB_SIZE_BYTES,
37
37
  HOURLY_BACKUP_GLOB,
38
+ LOCAL_CONTEXT_TABLES,
38
39
  MIN_REFERENCE_ROWS,
39
40
  PROTECTED_TABLES,
40
41
  WIPE_THRESHOLD_PCT,
@@ -48,6 +49,8 @@ from db_guard import (
48
49
  validate_backup_matches_source,
49
50
  )
50
51
 
52
+ RECOVERY_TABLES = PROTECTED_TABLES + LOCAL_CONTEXT_TABLES
53
+
51
54
  # Path resolution moved to lazy helpers (AUDITOR-V700-PASS2 §11, B10 item 3)
52
55
  # to keep monkeypatched NEXO_HOME / paths.* fixtures honoured. PEP 562
53
56
  # ``__getattr__`` below preserves the legacy constant names for any caller
@@ -139,7 +142,7 @@ def _describe_backup(path: Path, kind: str) -> dict:
139
142
  counts: dict[str, int | None] = {}
140
143
  critical_rows = 0
141
144
  if size > EMPTY_DB_SIZE_BYTES:
142
- counts = db_row_counts(path, PROTECTED_TABLES)
145
+ counts = db_row_counts(path, RECOVERY_TABLES)
143
146
  critical_rows = sum(v for v in counts.values() if isinstance(v, int))
144
147
  return {
145
148
  "path": str(path),
@@ -240,7 +243,7 @@ def recover(
240
243
  result["source"] = str(chosen)
241
244
  result["steps"].append(f"chose source: {chosen}")
242
245
 
243
- source_counts = db_row_counts(chosen, PROTECTED_TABLES)
246
+ source_counts = db_row_counts(chosen, RECOVERY_TABLES)
244
247
  result["source_row_counts"] = {k: v for k, v in source_counts.items() if v is not None}
245
248
  source_total = sum(v for v in source_counts.values() if isinstance(v, int))
246
249
  if source_total < MIN_REFERENCE_ROWS:
@@ -317,7 +320,7 @@ def recover(
317
320
  return result
318
321
  result["steps"].append(f"restored {chosen.name} -> {target_path}")
319
322
 
320
- valid, valid_err = validate_backup_matches_source(chosen, target_path, PROTECTED_TABLES)
323
+ valid, valid_err = validate_backup_matches_source(chosen, target_path, RECOVERY_TABLES)
321
324
  if not valid:
322
325
  result["errors"].append(f"post-restore validation failed: {valid_err}")
323
326
  if stopped_launchagents:
@@ -325,7 +328,7 @@ def recover(
325
328
  return result
326
329
  result["steps"].append("validated post-restore row counts")
327
330
 
328
- final_counts = db_row_counts(target_path, PROTECTED_TABLES)
331
+ final_counts = db_row_counts(target_path, RECOVERY_TABLES)
329
332
  result["final_row_counts"] = {k: v for k, v in final_counts.items() if v is not None}
330
333
  if stopped_launchagents:
331
334
  result["resume"] = resume_nexo_launchagents(stopped_launchagents)
@@ -485,8 +485,14 @@ def _row_count_regression(pre: dict[str, int | None], post: dict[str, int | None
485
485
  drop >= WIPE_THRESHOLD_PCT across CRITICAL_TABLES, is treated as a wipe.
486
486
  """
487
487
  regressions: list[str] = []
488
- pre_total = sum(v for v in pre.values() if isinstance(v, int))
489
- post_total = sum(v for v in post.values() if isinstance(v, int))
488
+ pre_total = sum(
489
+ pre.get(table) for table in PROTECTED_TABLES
490
+ if isinstance(pre.get(table), int)
491
+ )
492
+ post_total = sum(
493
+ post.get(table) for table in PROTECTED_TABLES
494
+ if isinstance(post.get(table), int)
495
+ )
490
496
  for table in PROTECTED_TABLES:
491
497
  pre_v = pre.get(table)
492
498
  post_v = post.get(table)
package/src/server.py CHANGED
@@ -810,7 +810,7 @@ def nexo_local_index_roots(action: str = "list", path: str = "", mode: str = "no
810
810
  """List, add or remove local memory roots."""
811
811
  normalized = str(action or "list").strip().lower()
812
812
  if normalized == "list":
813
- result = {"ok": True, "roots": local_context_api.list_roots()}
813
+ result = {"ok": True, "roots": local_context_api.list_roots(readonly=True)}
814
814
  elif normalized == "add":
815
815
  result = local_context_api.add_root(path, mode=mode, depth=depth)
816
816
  elif normalized == "remove":
@@ -825,7 +825,7 @@ def nexo_local_index_exclusions(action: str = "list", path: str = "", reason: st
825
825
  """List, add or remove local memory exclusions."""
826
826
  normalized = str(action or "list").strip().lower()
827
827
  if normalized == "list":
828
- result = {"ok": True, "exclusions": local_context_api.list_exclusions()}
828
+ result = {"ok": True, "exclusions": local_context_api.list_exclusions(readonly=True)}
829
829
  elif normalized == "add":
830
830
  result = local_context_api.add_exclusion(path, reason=reason)
831
831
  elif normalized == "remove":
@@ -474,9 +474,12 @@ def handle_startup(
474
474
  startup_warnings,
475
475
  )
476
476
  memory_maintenance = None
477
- if _env_flag("NEXO_MEMORY_MAINTENANCE_IN_STARTUP", default=False):
477
+ try:
478
+ backfill_limit = int(os.environ.get("NEXO_MEMORY_STARTUP_BACKFILL_LIMIT", "0") or "0")
479
+ except Exception:
480
+ backfill_limit = 0
481
+ if _env_flag("NEXO_MEMORY_MAINTENANCE_IN_STARTUP", default=False) or backfill_limit > 0:
478
482
  try:
479
- backfill_limit = int(os.environ.get("NEXO_MEMORY_STARTUP_BACKFILL_LIMIT", "0") or "0")
480
483
  memory_maintenance = maintain_memory_observations(
481
484
  process_limit=int(os.environ.get("NEXO_MEMORY_STARTUP_PROCESS_LIMIT", "20") or "20"),
482
485
  retry_failed=True,
@@ -783,7 +786,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
783
786
  if bundle.get("has_matches"):
784
787
  parts.append("")
785
788
  parts.append(format_pre_action_context_bundle(bundle, compact=True))
786
- if _env_flag("NEXO_HEARTBEAT_LOCAL_CONTEXT", default=False) and append_local_context_evidence is not None:
789
+ if _env_flag("NEXO_HEARTBEAT_LOCAL_CONTEXT", default=True) and append_local_context_evidence is not None:
787
790
  local_rendered = append_local_context_evidence("", recent_query, limit=4).strip()
788
791
  if local_rendered:
789
792
  parts.append("")
@@ -884,7 +887,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
884
887
 
885
888
  # ── Drive/Curiosity: detect signals from context_hint (best-effort) ──
886
889
  try:
887
- if _env_flag("NEXO_DRIVE_IN_HEARTBEAT", default=False) and context_hint and len(context_hint.strip()) >= 15:
890
+ if _env_flag("NEXO_DRIVE_IN_HEARTBEAT", default=True) and context_hint and len(context_hint.strip()) >= 15:
888
891
  from tools_drive import detect_drive_signal as _detect_drive
889
892
  _drive_allow_llm = _env_flag("NEXO_DRIVE_LLM_IN_HEARTBEAT", default=False)
890
893
  _drive_result = _detect_drive(
@@ -990,7 +993,7 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
990
993
  # adaptive_log row per heartbeat. Wrapped in best-effort try/except so
991
994
  # a failure here cannot block the heartbeat itself.
992
995
  try:
993
- if _env_flag("NEXO_HEARTBEAT_ADAPTIVE_MODE", default=False) and context_hint and len(context_hint.strip()) >= 5:
996
+ if _env_flag("NEXO_HEARTBEAT_ADAPTIVE_MODE", default=True) and context_hint and len(context_hint.strip()) >= 5:
994
997
  from plugins.adaptive_mode import compute_mode
995
998
  from cognitive._trust import detect_sentiment
996
999
  sentiment = detect_sentiment(context_hint)
@@ -1334,9 +1337,7 @@ def _hint_suggests_correction(hint: str, *, correction_detector=None) -> bool:
1334
1337
  text = (hint or "").strip()
1335
1338
  if not text:
1336
1339
  return False
1337
- detector = correction_detector if correction_detector is not None else (
1338
- _detect_correction_semantic if _env_flag("NEXO_HEARTBEAT_SEMANTIC_DETECTORS", default=False) else None
1339
- )
1340
+ detector = correction_detector if correction_detector is not None else _detect_correction_semantic
1340
1341
  if detector is not None:
1341
1342
  try:
1342
1343
  if bool(detector(text)):
@@ -1375,6 +1376,8 @@ def _hint_suggests_correction(hint: str, *, correction_detector=None) -> bool:
1375
1376
 
1376
1377
  def _recent_learning_capture_exists(conn, sid: str, window_seconds: int = 300) -> bool:
1377
1378
  """Check whether a recent learning was captured manually or via protocol task close."""
1379
+ if conn is None:
1380
+ conn = get_db()
1378
1381
  cutoff_epoch = time.time() - window_seconds
1379
1382
 
1380
1383
  row = conn.execute(