nexo-brain 7.20.21 → 7.20.23
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/package.json +1 -1
- package/src/auto_update.py +49 -19
- package/src/cli.py +21 -5
- package/src/db_guard.py +27 -9
- package/src/doctor/providers/boot.py +91 -2
- package/src/interactive_db.py +59 -0
- package/src/local_context/api.py +274 -81
- package/src/local_context/db.py +336 -0
- package/src/local_context/logging.py +3 -4
- package/src/mcp_required_tools.py +31 -0
- package/src/plugins/episodic_memory.py +18 -0
- package/src/plugins/recover.py +7 -4
- package/src/plugins/skills.py +14 -3
- package/src/plugins/update.py +37 -12
- package/src/scripts/nexo-backup.sh +131 -7
- package/src/server.py +97 -7
- package/src/tools_reminders.py +37 -8
- package/src/tools_sessions.py +11 -19
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import paths
|
|
11
|
+
from db._schema import _m63_local_context_layer, _m64_local_context_live_dirs
|
|
12
|
+
|
|
13
|
+
LOCAL_CONTEXT_DB_NAME = "local-context.db"
|
|
14
|
+
MIGRATION_STATE_KEY = "local_context_db_migrated_from_main"
|
|
15
|
+
MIGRATION_SKIPPED_KEY = "local_context_db_migration_skipped"
|
|
16
|
+
MAIN_CLEANUP_STATE_KEY = "local_context_main_tables_drained"
|
|
17
|
+
|
|
18
|
+
LOCAL_CONTEXT_TABLES: tuple[str, ...] = (
|
|
19
|
+
"local_index_roots",
|
|
20
|
+
"local_index_exclusions",
|
|
21
|
+
"local_index_jobs",
|
|
22
|
+
"local_index_checkpoints",
|
|
23
|
+
"local_index_state",
|
|
24
|
+
"local_index_errors",
|
|
25
|
+
"local_index_logs",
|
|
26
|
+
"local_assets",
|
|
27
|
+
"local_asset_versions",
|
|
28
|
+
"local_chunks",
|
|
29
|
+
"local_entities",
|
|
30
|
+
"local_relations",
|
|
31
|
+
"local_embeddings",
|
|
32
|
+
"local_context_queries",
|
|
33
|
+
"local_index_dirs",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_CONN: sqlite3.Connection | None = None
|
|
37
|
+
_CONN_PATH: Path | None = None
|
|
38
|
+
_READY = False
|
|
39
|
+
_LAST_MIGRATION_ATTEMPT = 0.0
|
|
40
|
+
_MIGRATION_RETRY_INTERVAL_SECONDS = 300.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def local_context_db_path() -> Path:
|
|
44
|
+
override = os.environ.get("NEXO_LOCAL_CONTEXT_DB", "").strip()
|
|
45
|
+
if override:
|
|
46
|
+
return Path(override).expanduser()
|
|
47
|
+
test_db = os.environ.get("NEXO_TEST_DB", "").strip()
|
|
48
|
+
if test_db:
|
|
49
|
+
return Path(test_db).expanduser().with_name("test_local_context.db")
|
|
50
|
+
return paths.memory_dir() / LOCAL_CONTEXT_DB_NAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _main_db_path_for_migration() -> Path:
|
|
54
|
+
override = os.environ.get("NEXO_LOCAL_CONTEXT_MAIN_DB", "").strip()
|
|
55
|
+
if override:
|
|
56
|
+
return Path(override).expanduser()
|
|
57
|
+
test_db = os.environ.get("NEXO_TEST_DB", "").strip()
|
|
58
|
+
if test_db:
|
|
59
|
+
return Path(test_db).expanduser()
|
|
60
|
+
return paths.db_path()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _busy_timeout_ms() -> int:
|
|
64
|
+
raw = os.environ.get("NEXO_LOCAL_CONTEXT_DB_BUSY_TIMEOUT_MS", "15000")
|
|
65
|
+
try:
|
|
66
|
+
value = int(raw)
|
|
67
|
+
except Exception:
|
|
68
|
+
value = 15000
|
|
69
|
+
return max(1000, min(value, 60000))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
73
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
conn = sqlite3.connect(str(db_path), timeout=max(_busy_timeout_ms() / 1000.0, 1.0), check_same_thread=False)
|
|
75
|
+
conn.row_factory = sqlite3.Row
|
|
76
|
+
conn.execute(f"PRAGMA busy_timeout={_busy_timeout_ms()}")
|
|
77
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
78
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
79
|
+
conn.execute("PRAGMA temp_store=MEMORY")
|
|
80
|
+
return conn
|
|
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
|
+
|
|
100
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
101
|
+
_m63_local_context_layer(conn)
|
|
102
|
+
_m64_local_context_live_dirs(conn)
|
|
103
|
+
conn.execute("PRAGMA user_version=64")
|
|
104
|
+
conn.commit()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _table_exists(conn: sqlite3.Connection, table: str, *, schema: str = "main") -> bool:
|
|
108
|
+
row = conn.execute(
|
|
109
|
+
f"SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
|
110
|
+
(table,),
|
|
111
|
+
).fetchone()
|
|
112
|
+
return bool(row)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _table_count(conn: sqlite3.Connection, table: str, *, schema: str = "main") -> int:
|
|
116
|
+
if not _table_exists(conn, table, schema=schema):
|
|
117
|
+
return 0
|
|
118
|
+
row = conn.execute(f"SELECT COUNT(*) AS total FROM {schema}.{_quoted(table)}").fetchone()
|
|
119
|
+
return int(row["total"] or 0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _state(conn: sqlite3.Connection, key: str) -> str:
|
|
123
|
+
row = conn.execute("SELECT value FROM local_index_state WHERE key=?", (key,)).fetchone()
|
|
124
|
+
return str(row["value"] or "") if row else ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _set_state(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
128
|
+
conn.execute(
|
|
129
|
+
"""
|
|
130
|
+
INSERT INTO local_index_state(key, value, updated_at)
|
|
131
|
+
VALUES (?, ?, strftime('%s','now'))
|
|
132
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
|
133
|
+
""",
|
|
134
|
+
(key, value),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _quoted(name: str) -> str:
|
|
139
|
+
return '"' + name.replace('"', '""') + '"'
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _table_columns(conn: sqlite3.Connection, table: str, *, schema: str = "main") -> list[str]:
|
|
143
|
+
try:
|
|
144
|
+
rows = conn.execute(f"PRAGMA {schema}.table_info({_quoted(table)})").fetchall()
|
|
145
|
+
except sqlite3.OperationalError:
|
|
146
|
+
return []
|
|
147
|
+
return [str(row["name"]) for row in rows if row["name"]]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _primary_key_columns(conn: sqlite3.Connection, table: str, *, schema: str = "main") -> list[str]:
|
|
151
|
+
try:
|
|
152
|
+
rows = conn.execute(f"PRAGMA {schema}.table_info({_quoted(table)})").fetchall()
|
|
153
|
+
except sqlite3.OperationalError:
|
|
154
|
+
return []
|
|
155
|
+
ordered = sorted(
|
|
156
|
+
(
|
|
157
|
+
(int(row["pk"] or 0), str(row["name"]))
|
|
158
|
+
for row in rows
|
|
159
|
+
if int(row["pk"] or 0) > 0 and row["name"]
|
|
160
|
+
),
|
|
161
|
+
key=lambda item: item[0],
|
|
162
|
+
)
|
|
163
|
+
return [name for _order, name in ordered]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _source_rows_missing_in_target(conn: sqlite3.Connection, table: str) -> int:
|
|
167
|
+
target_pk = _primary_key_columns(conn, table)
|
|
168
|
+
source_columns = set(_table_columns(conn, table, schema="source"))
|
|
169
|
+
pk_columns = [column for column in target_pk if column in source_columns]
|
|
170
|
+
if not pk_columns:
|
|
171
|
+
raise RuntimeError(f"cannot verify local context migration for {table}: missing primary key")
|
|
172
|
+
join_sql = " AND ".join(f"s.{_quoted(column)} = t.{_quoted(column)}" for column in pk_columns)
|
|
173
|
+
null_check = f"t.{_quoted(pk_columns[0])} IS NULL"
|
|
174
|
+
row = conn.execute(
|
|
175
|
+
f"SELECT COUNT(*) AS total "
|
|
176
|
+
f"FROM source.{_quoted(table)} s "
|
|
177
|
+
f"LEFT JOIN {_quoted(table)} t ON {join_sql} "
|
|
178
|
+
f"WHERE {null_check}"
|
|
179
|
+
).fetchone()
|
|
180
|
+
return int(row["total"] or 0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _copy_local_tables(conn: sqlite3.Connection, tables: Iterable[str]) -> dict[str, int]:
|
|
184
|
+
copied: dict[str, int] = {}
|
|
185
|
+
for table in tables:
|
|
186
|
+
if not _table_exists(conn, table, schema="source"):
|
|
187
|
+
continue
|
|
188
|
+
target_columns = _table_columns(conn, table)
|
|
189
|
+
source_columns = set(_table_columns(conn, table, schema="source"))
|
|
190
|
+
columns = [column for column in target_columns if column in source_columns]
|
|
191
|
+
if not columns:
|
|
192
|
+
continue
|
|
193
|
+
before = _table_count(conn, table)
|
|
194
|
+
column_sql = ", ".join(_quoted(column) for column in columns)
|
|
195
|
+
conn.execute(
|
|
196
|
+
f"INSERT OR IGNORE INTO {_quoted(table)} ({column_sql}) "
|
|
197
|
+
f"SELECT {column_sql} FROM source.{_quoted(table)}"
|
|
198
|
+
)
|
|
199
|
+
after = _table_count(conn, table)
|
|
200
|
+
copied[table] = max(0, after - before)
|
|
201
|
+
return copied
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _source_table_counts(conn: sqlite3.Connection, tables: Iterable[str]) -> dict[str, int]:
|
|
205
|
+
counts: dict[str, int] = {}
|
|
206
|
+
for table in tables:
|
|
207
|
+
if _table_exists(conn, table, schema="source"):
|
|
208
|
+
counts[table] = _table_count(conn, table, schema="source")
|
|
209
|
+
return counts
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _drain_source_local_tables_if_verified(
|
|
213
|
+
conn: sqlite3.Connection,
|
|
214
|
+
source_counts: dict[str, int],
|
|
215
|
+
) -> dict[str, int]:
|
|
216
|
+
"""Clear old local-memory rows from main DB after verifying the copy.
|
|
217
|
+
|
|
218
|
+
We keep the table schema in ``nexo.db`` for backward compatibility with
|
|
219
|
+
older binaries, but empty the rows after the new DB has at least the same
|
|
220
|
+
number of records per table. This prevents two sources of truth and stops
|
|
221
|
+
the main DB from carrying the huge local-memory payload.
|
|
222
|
+
"""
|
|
223
|
+
drained: dict[str, int] = {}
|
|
224
|
+
if not source_counts:
|
|
225
|
+
return drained
|
|
226
|
+
|
|
227
|
+
for table, source_count in source_counts.items():
|
|
228
|
+
local_count = _table_count(conn, table)
|
|
229
|
+
missing = _source_rows_missing_in_target(conn, table)
|
|
230
|
+
if local_count < source_count or missing:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"local context migration verification failed for {table}: "
|
|
233
|
+
f"local={local_count} source={source_count} missing={missing}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
for table, source_count in source_counts.items():
|
|
237
|
+
if source_count <= 0:
|
|
238
|
+
continue
|
|
239
|
+
conn.execute(f"DELETE FROM source.{_quoted(table)}")
|
|
240
|
+
drained[table] = source_count
|
|
241
|
+
|
|
242
|
+
if drained:
|
|
243
|
+
_set_state(conn, MAIN_CLEANUP_STATE_KEY, ",".join(sorted(drained)))
|
|
244
|
+
return drained
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def migrate_from_main_if_needed(conn: sqlite3.Connection) -> dict:
|
|
248
|
+
if os.environ.get("NEXO_LOCAL_CONTEXT_DISABLE_MAIN_MIGRATION", "").strip().lower() in {"1", "true", "yes"}:
|
|
249
|
+
return {"ok": True, "skipped": "disabled"}
|
|
250
|
+
|
|
251
|
+
main_db = _main_db_path_for_migration()
|
|
252
|
+
if not main_db.is_file():
|
|
253
|
+
_set_state(conn, MIGRATION_SKIPPED_KEY, "main_db_missing")
|
|
254
|
+
conn.commit()
|
|
255
|
+
return {"ok": True, "skipped": "main_db_missing"}
|
|
256
|
+
|
|
257
|
+
source = str(main_db).replace("'", "''")
|
|
258
|
+
try:
|
|
259
|
+
conn.execute("PRAGMA busy_timeout=1000")
|
|
260
|
+
conn.execute(f"ATTACH DATABASE '{source}' AS source")
|
|
261
|
+
if not _table_exists(conn, "local_assets", schema="source") and not _table_exists(conn, "local_index_roots", schema="source"):
|
|
262
|
+
_set_state(conn, MIGRATION_SKIPPED_KEY, "main_local_tables_missing")
|
|
263
|
+
if _table_count(conn, "local_assets") > 0 or _table_count(conn, "local_index_jobs") > 0:
|
|
264
|
+
_set_state(conn, MIGRATION_STATE_KEY, "already_has_local_data")
|
|
265
|
+
conn.commit()
|
|
266
|
+
return {"ok": True, "skipped": "main_local_tables_missing"}
|
|
267
|
+
source_counts = _source_table_counts(conn, LOCAL_CONTEXT_TABLES)
|
|
268
|
+
if not any(source_counts.values()):
|
|
269
|
+
_set_state(conn, MIGRATION_STATE_KEY, "main_local_tables_empty")
|
|
270
|
+
_set_state(conn, MAIN_CLEANUP_STATE_KEY, "empty")
|
|
271
|
+
conn.commit()
|
|
272
|
+
return {"ok": True, "skipped": "main_local_tables_empty"}
|
|
273
|
+
copied = _copy_local_tables(conn, LOCAL_CONTEXT_TABLES)
|
|
274
|
+
drained = _drain_source_local_tables_if_verified(conn, source_counts)
|
|
275
|
+
_set_state(conn, MIGRATION_STATE_KEY, str(main_db))
|
|
276
|
+
_set_state(conn, "local_context_db_migrated_rows", str(sum(copied.values())))
|
|
277
|
+
if drained:
|
|
278
|
+
_set_state(conn, "local_context_main_tables_drained_rows", str(sum(drained.values())))
|
|
279
|
+
conn.commit()
|
|
280
|
+
return {"ok": True, "migrated_from": str(main_db), "copied": copied, "drained": drained}
|
|
281
|
+
except sqlite3.OperationalError as exc:
|
|
282
|
+
message = str(exc)
|
|
283
|
+
_set_state(conn, MIGRATION_SKIPPED_KEY, message[:240])
|
|
284
|
+
_set_state(conn, "local_context_db_drain_pending", "main_db_busy_or_unavailable")
|
|
285
|
+
conn.commit()
|
|
286
|
+
return {"ok": True, "skipped": "main_db_busy_or_unavailable", "error": message, "retry_pending": True}
|
|
287
|
+
finally:
|
|
288
|
+
try:
|
|
289
|
+
conn.execute("DETACH DATABASE source")
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
conn.execute(f"PRAGMA busy_timeout={_busy_timeout_ms()}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def ensure_local_context_db() -> None:
|
|
296
|
+
global _CONN, _CONN_PATH, _READY, _LAST_MIGRATION_ATTEMPT
|
|
297
|
+
db_path = local_context_db_path()
|
|
298
|
+
if _CONN is not None and _CONN_PATH != db_path:
|
|
299
|
+
close_local_context_db()
|
|
300
|
+
if _CONN is None:
|
|
301
|
+
_CONN = _connect(db_path)
|
|
302
|
+
_CONN_PATH = db_path
|
|
303
|
+
now = time.monotonic()
|
|
304
|
+
if _READY:
|
|
305
|
+
if now - _LAST_MIGRATION_ATTEMPT >= _MIGRATION_RETRY_INTERVAL_SECONDS:
|
|
306
|
+
_LAST_MIGRATION_ATTEMPT = now
|
|
307
|
+
try:
|
|
308
|
+
migrate_from_main_if_needed(_CONN)
|
|
309
|
+
except Exception:
|
|
310
|
+
# Sidecar data is still usable; cleanup from the old main DB is
|
|
311
|
+
# best-effort and will retry on later service cycles.
|
|
312
|
+
pass
|
|
313
|
+
return
|
|
314
|
+
_ensure_schema(_CONN)
|
|
315
|
+
_LAST_MIGRATION_ATTEMPT = now
|
|
316
|
+
migration = migrate_from_main_if_needed(_CONN)
|
|
317
|
+
_READY = True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_local_context_db() -> sqlite3.Connection:
|
|
321
|
+
ensure_local_context_db()
|
|
322
|
+
assert _CONN is not None
|
|
323
|
+
return _CONN
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def close_local_context_db() -> None:
|
|
327
|
+
global _CONN, _CONN_PATH, _READY, _LAST_MIGRATION_ATTEMPT
|
|
328
|
+
if _CONN is not None:
|
|
329
|
+
try:
|
|
330
|
+
_CONN.close()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
_CONN = None
|
|
334
|
+
_CONN_PATH = None
|
|
335
|
+
_READY = False
|
|
336
|
+
_LAST_MIGRATION_ATTEMPT = 0.0
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from db import
|
|
4
|
-
|
|
3
|
+
from .db import get_local_context_db
|
|
5
4
|
from .util import json_dumps, now
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
def log_event(level: str, event: str, message: str, **metadata) -> None:
|
|
9
|
-
conn =
|
|
8
|
+
conn = get_local_context_db()
|
|
10
9
|
conn.execute(
|
|
11
10
|
"""
|
|
12
11
|
INSERT INTO local_index_logs(created_at, level, event, message, metadata_json)
|
|
@@ -18,7 +17,7 @@ def log_event(level: str, event: str, message: str, **metadata) -> None:
|
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
def tail(limit: int = 100) -> list[dict]:
|
|
21
|
-
conn =
|
|
20
|
+
conn = get_local_context_db()
|
|
22
21
|
rows = conn.execute(
|
|
23
22
|
"""
|
|
24
23
|
SELECT created_at, level, event, message, metadata_json
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Required MCP tool contract shared by Brain and Desktop probes.
|
|
4
|
+
|
|
5
|
+
These tools are the minimum bootstrap surface NEXO Desktop needs before a
|
|
6
|
+
conversation can be considered healthy. Dynamic plugin loading may still add
|
|
7
|
+
more tools, but these names must always be present.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
BOOTSTRAP_REQUIRED_MCP_TOOLS: tuple[str, ...] = (
|
|
11
|
+
"nexo_startup",
|
|
12
|
+
"nexo_heartbeat",
|
|
13
|
+
"nexo_session_diary_read",
|
|
14
|
+
"nexo_reminders",
|
|
15
|
+
"nexo_smart_startup",
|
|
16
|
+
"nexo_task_open",
|
|
17
|
+
"nexo_task_close",
|
|
18
|
+
"nexo_task_acknowledge_guard",
|
|
19
|
+
"nexo_guard_check",
|
|
20
|
+
"nexo_learning_add",
|
|
21
|
+
"nexo_confidence_check",
|
|
22
|
+
"nexo_followup_create",
|
|
23
|
+
"nexo_protocol_debt_resolve",
|
|
24
|
+
"nexo_card_match",
|
|
25
|
+
"nexo_skill_match",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def missing_required_tools(tool_names: list[str] | tuple[str, ...] | set[str]) -> list[str]:
|
|
30
|
+
available = {str(name) for name in tool_names}
|
|
31
|
+
return [name for name in BOOTSTRAP_REQUIRED_MCP_TOOLS if name not in available]
|
|
@@ -10,6 +10,7 @@ from db import (
|
|
|
10
10
|
log_change, search_changes, update_change_commit,
|
|
11
11
|
recall, get_db, set_linked_outcomes_met,
|
|
12
12
|
)
|
|
13
|
+
from interactive_db import interactive_db_timeout, is_db_busy
|
|
13
14
|
|
|
14
15
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
15
16
|
_REPO_ABS_PREFIX = str(REPO_ROOT) + "/"
|
|
@@ -377,6 +378,23 @@ def handle_session_diary_read(session_id: str = '', last_n: int = 3, last_day: b
|
|
|
377
378
|
brief: If true, returns ONLY the last diary entry with summary + mental_state + context_next.
|
|
378
379
|
Use this at startup for fast context loading (~1K chars instead of full dump).
|
|
379
380
|
"""
|
|
381
|
+
with interactive_db_timeout():
|
|
382
|
+
try:
|
|
383
|
+
return _handle_session_diary_read_inner(session_id, last_n, last_day, domain, brief)
|
|
384
|
+
except Exception as exc:
|
|
385
|
+
if is_db_busy(exc):
|
|
386
|
+
return (
|
|
387
|
+
"SESSION DIARY DEGRADED:\n"
|
|
388
|
+
" local brain database is busy; continue with the user request and retry shortly."
|
|
389
|
+
)
|
|
390
|
+
return (
|
|
391
|
+
"SESSION DIARY DEGRADED:\n"
|
|
392
|
+
f" skipped ({type(exc).__name__}); continue with the user request and retry shortly."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _handle_session_diary_read_inner(session_id: str = '', last_n: int = 3, last_day: bool = False,
|
|
397
|
+
domain: str = '', brief: bool = False) -> str:
|
|
380
398
|
if brief:
|
|
381
399
|
# Fast path: only the most recent diary entry, compact format
|
|
382
400
|
results = read_session_diary(session_id, 1, last_day=False, domain=domain)
|
package/src/plugins/recover.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|
package/src/plugins/skills.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
+
from interactive_db import interactive_db_timeout, is_db_busy
|
|
7
8
|
from db import (
|
|
8
9
|
create_skill,
|
|
9
10
|
delete_skill,
|
|
@@ -85,12 +86,22 @@ def handle_skill_create(
|
|
|
85
86
|
|
|
86
87
|
|
|
87
88
|
def handle_skill_match(task: str, level: str = "") -> str:
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
sync_warning = ""
|
|
90
|
+
with interactive_db_timeout():
|
|
91
|
+
try:
|
|
92
|
+
sync_skills()
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
if is_db_busy(exc):
|
|
95
|
+
sync_warning = "SKILL SYNC DEGRADED: local brain database is busy; using existing skill index."
|
|
96
|
+
else:
|
|
97
|
+
raise
|
|
98
|
+
matches = match_skills(task, level=level)
|
|
90
99
|
if not matches:
|
|
91
|
-
return f"No skills found for: '{task}'"
|
|
100
|
+
return f"{sync_warning + chr(10) if sync_warning else ''}No skills found for: '{task}'"
|
|
92
101
|
|
|
93
102
|
lines = [f"SKILLS MATCHED ({len(matches)}) for '{task}':"]
|
|
103
|
+
if sync_warning:
|
|
104
|
+
lines.insert(0, sync_warning)
|
|
94
105
|
for match in matches:
|
|
95
106
|
match_method = match.pop("_match", "unknown")
|
|
96
107
|
lines.append(
|
package/src/plugins/update.py
CHANGED
|
@@ -39,6 +39,7 @@ try:
|
|
|
39
39
|
from db_guard import (
|
|
40
40
|
CRITICAL_TABLES,
|
|
41
41
|
HOURLY_BACKUP_MAX_AGE,
|
|
42
|
+
LOCAL_CONTEXT_TABLES,
|
|
42
43
|
MIN_REFERENCE_ROWS,
|
|
43
44
|
PROTECTED_TABLES,
|
|
44
45
|
WIPE_THRESHOLD_PCT,
|
|
@@ -56,6 +57,7 @@ except Exception as exc: # pragma: no cover - exercised only during stale insta
|
|
|
56
57
|
_DB_GUARD_AVAILABLE = False
|
|
57
58
|
_DB_GUARD_IMPORT_ERROR = str(exc)
|
|
58
59
|
CRITICAL_TABLES = ()
|
|
60
|
+
LOCAL_CONTEXT_TABLES = ()
|
|
59
61
|
PROTECTED_TABLES = ()
|
|
60
62
|
HOURLY_BACKUP_MAX_AGE = 48 * 3600
|
|
61
63
|
MIN_REFERENCE_ROWS = 50
|
|
@@ -98,6 +100,14 @@ REQUIRED_LOCAL_MEMORY_TABLES: tuple[str, ...] = (
|
|
|
98
100
|
"local_index_dirs",
|
|
99
101
|
)
|
|
100
102
|
|
|
103
|
+
|
|
104
|
+
def _backup_validation_tables(db_file: Path) -> tuple[str, ...]:
|
|
105
|
+
if db_file.name == "nexo.db":
|
|
106
|
+
return tuple(PROTECTED_TABLES)
|
|
107
|
+
if db_file.name == "local-context.db":
|
|
108
|
+
return tuple(LOCAL_CONTEXT_TABLES)
|
|
109
|
+
return ()
|
|
110
|
+
|
|
101
111
|
# Code root is the parent of plugins/:
|
|
102
112
|
# - source checkout: <repo>/src
|
|
103
113
|
# - packaged runtime: <NEXO_HOME>
|
|
@@ -404,11 +414,11 @@ def _db_guard_integrity_error() -> str | None:
|
|
|
404
414
|
f" import error: {_DB_GUARD_IMPORT_ERROR or 'unknown'}\n"
|
|
405
415
|
"Restart Desktop so it can sync the bundled Brain guard, then retry the update."
|
|
406
416
|
)
|
|
407
|
-
|
|
408
|
-
missing = [table for table in REQUIRED_LOCAL_MEMORY_TABLES if table not in
|
|
417
|
+
local_tables = set(LOCAL_CONTEXT_TABLES)
|
|
418
|
+
missing = [table for table in REQUIRED_LOCAL_MEMORY_TABLES if table not in local_tables]
|
|
409
419
|
if missing:
|
|
410
420
|
return (
|
|
411
|
-
"DB protection module is stale; refusing to update because local memory tables are not
|
|
421
|
+
"DB protection module is stale; refusing to update because local memory tables are not known.\n"
|
|
412
422
|
f" missing tables: {', '.join(missing)}\n"
|
|
413
423
|
"Restart Desktop so it can sync the bundled Brain guard, then retry the update."
|
|
414
424
|
)
|
|
@@ -475,8 +485,14 @@ def _row_count_regression(pre: dict[str, int | None], post: dict[str, int | None
|
|
|
475
485
|
drop >= WIPE_THRESHOLD_PCT across CRITICAL_TABLES, is treated as a wipe.
|
|
476
486
|
"""
|
|
477
487
|
regressions: list[str] = []
|
|
478
|
-
pre_total = sum(
|
|
479
|
-
|
|
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
|
+
)
|
|
480
496
|
for table in PROTECTED_TABLES:
|
|
481
497
|
pre_v = pre.get(table)
|
|
482
498
|
post_v = post.get(table)
|
|
@@ -505,6 +521,9 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
505
521
|
backup_dir = BACKUP_BASE / f"pre-update-{timestamp}"
|
|
506
522
|
|
|
507
523
|
db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
|
|
524
|
+
local_context_db = paths.memory_dir() / "local-context.db"
|
|
525
|
+
if local_context_db.is_file():
|
|
526
|
+
db_files.append(local_context_db)
|
|
508
527
|
# Also check NEXO_HOME root for legacy db location
|
|
509
528
|
db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
|
|
510
529
|
# And check src/ dir for nexo.db (dev mode)
|
|
@@ -522,10 +541,9 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
522
541
|
ok, err = safe_sqlite_backup(db_file, dest)
|
|
523
542
|
if not ok:
|
|
524
543
|
return str(backup_dir), f"Failed to backup {db_file.name}: {err}"
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
valid, valid_err = validate_backup_matches_source(db_file, dest, PROTECTED_TABLES)
|
|
544
|
+
tables = _backup_validation_tables(db_file)
|
|
545
|
+
if tables:
|
|
546
|
+
valid, valid_err = validate_backup_matches_source(db_file, dest, tables)
|
|
529
547
|
if not valid:
|
|
530
548
|
return str(backup_dir), (
|
|
531
549
|
f"Backup of {db_file.name} did not preserve critical tables: {valid_err}"
|
|
@@ -544,12 +562,19 @@ def _restore_databases(backup_dir: str):
|
|
|
544
562
|
if not bdir.is_dir():
|
|
545
563
|
return
|
|
546
564
|
for db_backup in bdir.glob("*.db"):
|
|
547
|
-
# Try to find original location
|
|
548
|
-
|
|
549
|
-
|
|
565
|
+
# Try to find original location. local-context.db lives outside the
|
|
566
|
+
# operational DB directory and must be restored even if the target was
|
|
567
|
+
# missing after a failed update.
|
|
568
|
+
if db_backup.name == "local-context.db":
|
|
569
|
+
candidates = [paths.memory_dir() / db_backup.name]
|
|
570
|
+
else:
|
|
571
|
+
candidates = [DATA_DIR / db_backup.name, NEXO_HOME / db_backup.name, SRC_DIR / db_backup.name]
|
|
572
|
+
for candidate in candidates:
|
|
573
|
+
if candidate.is_file() or db_backup.name == "local-context.db":
|
|
550
574
|
src_conn = None
|
|
551
575
|
dst_conn = None
|
|
552
576
|
try:
|
|
577
|
+
candidate.parent.mkdir(parents=True, exist_ok=True)
|
|
553
578
|
src_conn = sqlite3.connect(str(db_backup))
|
|
554
579
|
dst_conn = sqlite3.connect(str(candidate))
|
|
555
580
|
src_conn.backup(dst_conn)
|