nexo-brain 3.0.1 → 3.1.0

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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +82 -0
  3. package/community/launch/2026-04-v3-0-2/case-study-outreach.md +36 -0
  4. package/community/launch/2026-04-v3-0-2/devto-v3-0-2.md +91 -0
  5. package/community/launch/2026-04-v3-0-2/github-discussion-v3-0-2.md +58 -0
  6. package/community/launch/2026-04-v3-0-2/x-thread-v3-0-2.md +60 -0
  7. package/hooks/hooks.json +12 -0
  8. package/package.json +1 -1
  9. package/src/auto_update.py +23 -6
  10. package/src/client_sync.py +6 -0
  11. package/src/cognitive/_memory.py +14 -7
  12. package/src/cognitive/_search.py +12 -5
  13. package/src/crons/sync.py +2 -1
  14. package/src/doctor/models.py +25 -0
  15. package/src/doctor/orchestrator.py +32 -2
  16. package/src/doctor/providers/boot.py +7 -7
  17. package/src/doctor/providers/deep.py +24 -21
  18. package/src/doctor/providers/runtime.py +154 -137
  19. package/src/evolution_cycle.py +76 -47
  20. package/src/hook_guardrails.py +182 -0
  21. package/src/hooks/protocol-guardrail.sh +1 -1
  22. package/src/hooks/protocol-pretool-guardrail.sh +9 -0
  23. package/src/kg_populate.py +21 -19
  24. package/src/maintenance.py +3 -3
  25. package/src/migrate_embeddings.py +36 -34
  26. package/src/plugins/backup.py +24 -12
  27. package/src/plugins/protocol.py +15 -0
  28. package/src/plugins/schedule.py +13 -1
  29. package/src/plugins/update.py +18 -4
  30. package/src/protocol_settings.py +59 -0
  31. package/src/public_contribution.py +10 -14
  32. package/src/public_evolution_queue.py +241 -0
  33. package/src/scripts/nexo-catchup.py +15 -15
  34. package/src/scripts/nexo-daily-self-audit.py +677 -28
  35. package/src/scripts/nexo-evolution-run.py +44 -4
  36. package/src/server.py +26 -1
  37. package/src/state_watchers_runtime.py +42 -35
@@ -120,52 +120,54 @@ def save_objective(obj: dict):
120
120
  def get_week_data(db_path: str) -> dict:
121
121
  """Gather last 7 days of learnings, decisions, changes, diaries."""
122
122
  conn = sqlite3.connect(db_path, timeout=10)
123
- conn.row_factory = sqlite3.Row
124
- cutoff_epoch = time.time() - 7 * 86400
125
- cutoff_date = (date.today() - timedelta(days=7)).isoformat()
126
-
127
- data = {}
128
-
129
- rows = conn.execute(
130
- "SELECT category, title, content FROM learnings WHERE created_at > ? ORDER BY created_at DESC LIMIT 50",
131
- (cutoff_epoch,)
132
- ).fetchall()
133
- data["learnings"] = [dict(r) for r in rows]
134
-
135
- rows = conn.execute(
136
- "SELECT domain, decision, alternatives, based_on, confidence, outcome FROM decisions "
137
- "WHERE created_at > ? ORDER BY created_at DESC LIMIT 20",
138
- (cutoff_date,)
139
- ).fetchall()
140
- data["decisions"] = [dict(r) for r in rows]
141
-
142
- rows = conn.execute(
143
- "SELECT files, what_changed, why, affects, risks FROM change_log "
144
- "WHERE created_at > ? ORDER BY created_at DESC LIMIT 30",
145
- (cutoff_date,)
146
- ).fetchall()
147
- data["changes"] = [dict(r) for r in rows]
148
-
149
- rows = conn.execute(
150
- "SELECT summary, decisions as diary_decisions, pending, mental_state, domain, user_signals "
151
- "FROM session_diary WHERE created_at > ? ORDER BY created_at DESC LIMIT 20",
152
- (cutoff_date,)
153
- ).fetchall()
154
- data["diaries"] = [dict(r) for r in rows]
155
-
156
- rows = conn.execute(
157
- "SELECT * FROM evolution_log ORDER BY id DESC LIMIT 20"
158
- ).fetchall()
159
- data["evolution_history"] = [dict(r) for r in rows]
160
-
161
- rows = conn.execute(
162
- "SELECT dimension, score, delta, measured_at FROM evolution_metrics "
163
- "WHERE id IN (SELECT MAX(id) FROM evolution_metrics GROUP BY dimension)"
164
- ).fetchall()
165
- data["current_metrics"] = {r["dimension"]: dict(r) for r in rows}
166
-
167
- conn.close()
168
- return data
123
+ try:
124
+ conn.row_factory = sqlite3.Row
125
+ cutoff_epoch = time.time() - 7 * 86400
126
+ cutoff_date = (date.today() - timedelta(days=7)).isoformat()
127
+
128
+ data = {}
129
+
130
+ rows = conn.execute(
131
+ "SELECT category, title, content FROM learnings WHERE created_at > ? ORDER BY created_at DESC LIMIT 50",
132
+ (cutoff_epoch,)
133
+ ).fetchall()
134
+ data["learnings"] = [dict(r) for r in rows]
135
+
136
+ rows = conn.execute(
137
+ "SELECT domain, decision, alternatives, based_on, confidence, outcome FROM decisions "
138
+ "WHERE created_at > ? ORDER BY created_at DESC LIMIT 20",
139
+ (cutoff_date,)
140
+ ).fetchall()
141
+ data["decisions"] = [dict(r) for r in rows]
142
+
143
+ rows = conn.execute(
144
+ "SELECT files, what_changed, why, affects, risks FROM change_log "
145
+ "WHERE created_at > ? ORDER BY created_at DESC LIMIT 30",
146
+ (cutoff_date,)
147
+ ).fetchall()
148
+ data["changes"] = [dict(r) for r in rows]
149
+
150
+ rows = conn.execute(
151
+ "SELECT summary, decisions as diary_decisions, pending, mental_state, domain, user_signals "
152
+ "FROM session_diary WHERE created_at > ? ORDER BY created_at DESC LIMIT 20",
153
+ (cutoff_date,)
154
+ ).fetchall()
155
+ data["diaries"] = [dict(r) for r in rows]
156
+
157
+ rows = conn.execute(
158
+ "SELECT * FROM evolution_log ORDER BY id DESC LIMIT 20"
159
+ ).fetchall()
160
+ data["evolution_history"] = [dict(r) for r in rows]
161
+
162
+ rows = conn.execute(
163
+ "SELECT dimension, score, delta, measured_at FROM evolution_metrics "
164
+ "WHERE id IN (SELECT MAX(id) FROM evolution_metrics GROUP BY dimension)"
165
+ ).fetchall()
166
+ data["current_metrics"] = {r["dimension"]: dict(r) for r in rows}
167
+
168
+ return data
169
+ finally:
170
+ conn.close()
169
171
 
170
172
 
171
173
  def create_snapshot(files_to_backup: list) -> str:
@@ -348,7 +350,12 @@ Max 3 proposals. Quality over quantity. If nothing needs improving, say so."""
348
350
  return prompt
349
351
 
350
352
 
351
- def build_public_contribution_prompt(*, repo_root: str, cycle_number: int) -> str:
353
+ def build_public_contribution_prompt(
354
+ *,
355
+ repo_root: str,
356
+ cycle_number: int,
357
+ queued_candidate: dict | None = None,
358
+ ) -> str:
352
359
  """Prompt for the public-core contributor mode.
353
360
 
354
361
  This prompt must never rely on private runtime state. It should inspect only
@@ -356,6 +363,27 @@ def build_public_contribution_prompt(*, repo_root: str, cycle_number: int) -> st
356
363
  by returning machine-readable summary JSON.
357
364
  """
358
365
 
366
+ queued_section = ""
367
+ if queued_candidate:
368
+ queued_files = "\n".join(
369
+ f"- {path}" for path in (queued_candidate.get("files_changed") or [])[:20]
370
+ ) or "- (no files recorded)"
371
+ queued_source = str((queued_candidate.get("metadata") or {}).get("source") or "managed-runtime")
372
+ queued_section = f"""
373
+
374
+ PRIORITY PUBLIC-PORT QUEUE ITEM:
375
+ - Source: {queued_source}
376
+ - Title: {str(queued_candidate.get("title") or "").strip()}
377
+ - Why it matters: {str(queued_candidate.get("reasoning") or "").strip()}
378
+ - Files originally touched:
379
+ {queued_files}
380
+
381
+ This item was already fixed or detected outside the public contribution runner.
382
+ Before inventing another improvement, verify whether the public repository still
383
+ needs the same change and port it if necessary. If the repo is already correct,
384
+ make the smallest validating change that captures the same gap.
385
+ """
386
+
359
387
  return f"""You are NEXO Public Evolution.
360
388
 
361
389
  You are running inside an isolated checkout of the public NEXO repository.
@@ -387,6 +415,7 @@ What to do:
387
415
 
388
416
  Cycle: #{cycle_number}
389
417
  Quality over quantity. One strong improvement is better than three weak ones.
418
+ {queued_section}
390
419
  """
391
420
 
392
421
 
@@ -3,11 +3,13 @@ from __future__ import annotations
3
3
  """Post-tool guardrails for conditioned file learnings."""
4
4
 
5
5
  import json
6
+ import os
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
9
10
  from db import create_protocol_debt, get_db
10
11
  from plugins.guard import _load_conditioned_learnings, _normalize_path_token
12
+ from protocol_settings import get_protocol_strictness
11
13
 
12
14
  READ_LIKE_TOOLS = {"Read"}
13
15
  WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
@@ -94,6 +96,18 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
94
96
  return None
95
97
 
96
98
 
99
+ def _find_any_open_task(conn, sid: str) -> dict | None:
100
+ row = conn.execute(
101
+ """SELECT task_id, files, guard_has_blocking
102
+ FROM protocol_tasks
103
+ WHERE session_id = ? AND status = 'open'
104
+ ORDER BY opened_at DESC
105
+ LIMIT 1""",
106
+ (sid,),
107
+ ).fetchone()
108
+ return dict(row) if row else None
109
+
110
+
97
111
  def _find_open_debt(conn, *, session_id: str, task_id: str, debt_type: str, file_token: str) -> dict | None:
98
112
  row = conn.execute(
99
113
  """SELECT *
@@ -152,6 +166,136 @@ def _ensure_protocol_debt(
152
166
  )
153
167
 
154
168
 
169
+ def process_pre_tool_event(payload: dict) -> dict:
170
+ strictness = get_protocol_strictness()
171
+ tool_name = str(payload.get("tool_name", "")).strip()
172
+ op = _operation_kind(tool_name)
173
+ if strictness == "lenient":
174
+ return {"ok": True, "skipped": True, "reason": "lenient mode", "strictness": strictness}
175
+ if op not in {"write", "delete"}:
176
+ return {"ok": True, "skipped": True, "reason": "operation not blocked", "strictness": strictness}
177
+
178
+ tool_input = payload.get("tool_input")
179
+ files = _extract_touched_files(tool_input)
180
+ conn = get_db()
181
+ sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
182
+ blocks: list[dict] = []
183
+
184
+ if not sid:
185
+ debt = _ensure_protocol_debt(
186
+ conn,
187
+ session_id="",
188
+ task_id="",
189
+ debt_type="strict_protocol_write_without_startup",
190
+ severity="error",
191
+ evidence=f"{tool_name} attempted before nexo_startup/session mapping.",
192
+ file_token="startup",
193
+ )
194
+ blocks.append(
195
+ {
196
+ "file": "",
197
+ "task_id": "",
198
+ "debt_id": debt.get("id"),
199
+ "debt_type": "strict_protocol_write_without_startup",
200
+ "reason_code": "missing_startup",
201
+ }
202
+ )
203
+ return {
204
+ "ok": True,
205
+ "session_id": sid,
206
+ "tool_name": tool_name,
207
+ "operation": op,
208
+ "strictness": strictness,
209
+ "blocks": blocks,
210
+ "status": "blocked",
211
+ }
212
+
213
+ if not files:
214
+ task = _find_any_open_task(conn, sid)
215
+ if not task:
216
+ debt = _ensure_protocol_debt(
217
+ conn,
218
+ session_id=sid,
219
+ task_id="",
220
+ debt_type="strict_protocol_write_without_task",
221
+ severity="error",
222
+ evidence=f"{tool_name} attempted without a detectable file path and without an open protocol task.",
223
+ file_token="unknown-target",
224
+ )
225
+ blocks.append(
226
+ {
227
+ "file": "",
228
+ "task_id": "",
229
+ "debt_id": debt.get("id"),
230
+ "debt_type": "strict_protocol_write_without_task",
231
+ "reason_code": "missing_task",
232
+ }
233
+ )
234
+ return {
235
+ "ok": True,
236
+ "session_id": sid,
237
+ "tool_name": tool_name,
238
+ "operation": op,
239
+ "strictness": strictness,
240
+ "blocks": blocks,
241
+ "status": "blocked" if blocks else "clean",
242
+ }
243
+
244
+ for filepath in files:
245
+ task = _find_open_task_for_file(conn, sid, filepath)
246
+ if not task:
247
+ debt = _ensure_protocol_debt(
248
+ conn,
249
+ session_id=sid,
250
+ task_id="",
251
+ debt_type="strict_protocol_write_without_task",
252
+ severity="error",
253
+ evidence=f"{tool_name} attempted on {filepath} without an open protocol task for that file.",
254
+ file_token=filepath,
255
+ )
256
+ blocks.append(
257
+ {
258
+ "file": filepath,
259
+ "task_id": "",
260
+ "debt_id": debt.get("id"),
261
+ "debt_type": "strict_protocol_write_without_task",
262
+ "reason_code": "missing_task",
263
+ }
264
+ )
265
+ continue
266
+
267
+ guard_debt = _find_task_guard_blocking_debt(conn, task["task_id"])
268
+ if guard_debt:
269
+ debt = _ensure_protocol_debt(
270
+ conn,
271
+ session_id=sid,
272
+ task_id=task["task_id"],
273
+ debt_type="strict_protocol_write_without_guard_ack",
274
+ severity="error",
275
+ evidence=f"{tool_name} attempted on {filepath} before acknowledging guard debt for task {task['task_id']}.",
276
+ file_token=filepath,
277
+ )
278
+ blocks.append(
279
+ {
280
+ "file": filepath,
281
+ "task_id": task["task_id"],
282
+ "debt_id": debt.get("id"),
283
+ "debt_type": "strict_protocol_write_without_guard_ack",
284
+ "reason_code": "guard_unacknowledged",
285
+ }
286
+ )
287
+
288
+ return {
289
+ "ok": True,
290
+ "session_id": sid,
291
+ "tool_name": tool_name,
292
+ "operation": op,
293
+ "strictness": strictness,
294
+ "blocks": blocks,
295
+ "status": "blocked" if blocks else "clean",
296
+ }
297
+
298
+
155
299
  def process_tool_event(payload: dict) -> dict:
156
300
  tool_name = str(payload.get("tool_name", "")).strip()
157
301
  op = _operation_kind(tool_name)
@@ -289,6 +433,38 @@ def format_hook_message(result: dict) -> str:
289
433
  return "\n".join(lines)
290
434
 
291
435
 
436
+ def format_pretool_block_message(result: dict) -> str:
437
+ blocks = result.get("blocks") or []
438
+ if not blocks:
439
+ return ""
440
+ strictness = str(result.get("strictness") or "strict")
441
+ header = (
442
+ "NEXO LEARNING MODE BLOCKED THIS EDIT:"
443
+ if strictness == "learning"
444
+ else "NEXO STRICT MODE BLOCKED THIS EDIT:"
445
+ )
446
+ lines = [header]
447
+ for item in blocks:
448
+ file_note = item["file"] or "(unknown target)"
449
+ if item.get("reason_code") == "missing_startup":
450
+ lines.append(
451
+ f"- Start the shared-brain session first: call `nexo_startup`, then `nexo_task_open`, before editing {file_note}."
452
+ )
453
+ elif item.get("reason_code") == "guard_unacknowledged":
454
+ lines.append(
455
+ f"- {file_note}: task {item['task_id']} still has blocking guard debt. Acknowledge it with `nexo_task_acknowledge_guard` before retrying."
456
+ )
457
+ elif strictness == "learning":
458
+ lines.append(
459
+ f"- {file_note}: open `nexo_task_open(task_type='edit', files=['{file_note}'])` first, then rerun the edit."
460
+ )
461
+ else:
462
+ lines.append(
463
+ f"- {file_note}: open `nexo_task_open(... files=['{file_note}'])` before editing."
464
+ )
465
+ return "\n".join(lines)
466
+
467
+
292
468
  def main() -> int:
293
469
  raw = sys.stdin.read()
294
470
  if not raw.strip():
@@ -297,6 +473,12 @@ def main() -> int:
297
473
  payload = json.loads(raw)
298
474
  except Exception:
299
475
  return 0
476
+ if os.environ.get("NEXO_HOOK_PHASE", "").strip().lower() == "pre":
477
+ result = process_pre_tool_event(payload)
478
+ message = format_pretool_block_message(result)
479
+ if message:
480
+ print(message, file=sys.stderr)
481
+ return 2 if result.get("status") == "blocked" else 0
300
482
  result = process_tool_event(payload)
301
483
  message = format_hook_message(result)
302
484
  if message:
@@ -5,6 +5,6 @@ INPUT=$(cat || true)
5
5
  [ -z "$INPUT" ] && exit 0
6
6
 
7
7
  NEXO_CODE="${NEXO_CODE:-${HOME}/.nexo}"
8
- python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT" 2>/dev/null || true
8
+ NEXO_HOOK_PHASE=post python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT" 2>/dev/null || true
9
9
 
10
10
  exit 0
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ # NEXO PreToolUse hook — strict protocol blocking before writes/deletes
3
+
4
+ INPUT=$(cat || true)
5
+ [ -z "$INPUT" ] && exit 0
6
+
7
+ NEXO_CODE="${NEXO_CODE:-${HOME}/.nexo}"
8
+ NEXO_HOOK_PHASE=pre python3 "$NEXO_CODE/hook_guardrails.py" <<< "$INPUT"
9
+ exit $?
@@ -147,25 +147,27 @@ def backfill_decisions() -> int:
147
147
  def backfill_somatic() -> int:
148
148
  """Read somatic_markers from cognitive.db → create file/area nodes with risk."""
149
149
  cdb = _cognitive_db()
150
- rows = cdb.execute(
151
- "SELECT target, target_type, risk_score, incident_count FROM somatic_markers"
152
- ).fetchall()
153
- count = 0
154
- for row in rows:
155
- target_type = row["target_type"] or "file"
156
- node_ref = f"{target_type}:{row['target']}"
157
- kg.upsert_node(
158
- node_type=target_type,
159
- node_ref=node_ref,
160
- label=os.path.basename(row["target"]) or row["target"],
161
- properties={
162
- "risk_score": row["risk_score"],
163
- "incident_count": row["incident_count"],
164
- },
165
- )
166
- count += 1
167
- cdb.close()
168
- return count
150
+ try:
151
+ rows = cdb.execute(
152
+ "SELECT target, target_type, risk_score, incident_count FROM somatic_markers"
153
+ ).fetchall()
154
+ count = 0
155
+ for row in rows:
156
+ target_type = row["target_type"] or "file"
157
+ node_ref = f"{target_type}:{row['target']}"
158
+ kg.upsert_node(
159
+ node_type=target_type,
160
+ node_ref=node_ref,
161
+ label=os.path.basename(row["target"]) or row["target"],
162
+ properties={
163
+ "risk_score": row["risk_score"],
164
+ "incident_count": row["incident_count"],
165
+ },
166
+ )
167
+ count += 1
168
+ return count
169
+ finally:
170
+ cdb.close()
169
171
 
170
172
 
171
173
  def run_full_backfill() -> dict:
@@ -1,7 +1,7 @@
1
1
  """Opportunistic maintenance — run overdue tasks on MCP startup."""
2
2
 
3
3
  import time
4
- from datetime import datetime
4
+ from datetime import datetime, timezone
5
5
  from db import get_db
6
6
 
7
7
 
@@ -16,7 +16,7 @@ def check_and_run_overdue():
16
16
  if last_run:
17
17
  try:
18
18
  last_dt = datetime.strptime(last_run, "%Y-%m-%dT%H:%M:%S")
19
- hours_since = (datetime.now(datetime.timezone.utc).replace(tzinfo=None) - last_dt).total_seconds() / 3600
19
+ hours_since = (datetime.now(timezone.utc).replace(tzinfo=None) - last_dt).total_seconds() / 3600
20
20
  if hours_since < interval:
21
21
  continue
22
22
  except (ValueError, TypeError):
@@ -28,7 +28,7 @@ def check_and_run_overdue():
28
28
  conn.execute(
29
29
  "UPDATE maintenance_schedule SET last_run_at = ?, last_duration_ms = ?, "
30
30
  "run_count = run_count + 1 WHERE task_name = ?",
31
- (datetime.now(datetime.timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%S"), duration_ms, task))
31
+ (datetime.now(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%dT%H:%M:%S"), duration_ms, task))
32
32
  conn.commit()
33
33
  ran.append({"task": task, "duration_ms": duration_ms})
34
34
  except Exception as e:
@@ -30,15 +30,17 @@ MODELS = {
30
30
  def verify():
31
31
  """Check current embedding dimensions in the database."""
32
32
  conn = sqlite3.connect(DB_PATH)
33
- for table in ["stm_memories", "ltm_memories"]:
34
- count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
35
- if count == 0:
36
- print(f" {table}: {count} rows (empty)")
37
- continue
38
- row = conn.execute(f"SELECT embedding FROM {table} LIMIT 1").fetchone()
39
- vec = np.frombuffer(row[0], dtype=np.float32)
40
- print(f" {table}: {count} rows, embedding dim = {len(vec)}")
41
- conn.close()
33
+ try:
34
+ for table in ["stm_memories", "ltm_memories"]:
35
+ count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
36
+ if count == 0:
37
+ print(f" {table}: {count} rows (empty)")
38
+ continue
39
+ row = conn.execute(f"SELECT embedding FROM {table} LIMIT 1").fetchone()
40
+ vec = np.frombuffer(row[0], dtype=np.float32)
41
+ print(f" {table}: {count} rows, embedding dim = {len(vec)}")
42
+ finally:
43
+ conn.close()
42
44
 
43
45
 
44
46
  def upgrade():
@@ -62,31 +64,31 @@ def upgrade():
62
64
  model = TextEmbedding(model_name)
63
65
 
64
66
  conn = sqlite3.connect(DB_PATH)
65
-
66
- for table in ["stm_memories", "ltm_memories"]:
67
- rows = conn.execute(f"SELECT id, content FROM {table}").fetchall()
68
- if not rows:
69
- print(f"\n{table}: empty, skipping")
70
- continue
71
-
72
- print(f"\n{table}: re-embedding {len(rows)} memories...")
73
- t0 = time.time()
74
-
75
- # Batch embed for speed
76
- contents = [r[1] for r in rows]
77
- ids = [r[0] for r in rows]
78
-
79
- embeddings = list(model.embed(contents))
80
-
81
- for mem_id, emb in zip(ids, embeddings):
82
- blob = np.array(emb, dtype=np.float32).tobytes()
83
- conn.execute(f"UPDATE {table} SET embedding = ? WHERE id = ?", (blob, mem_id))
84
-
85
- conn.commit()
86
- elapsed = time.time() - t0
87
- print(f" Done: {len(rows)} memories in {elapsed:.1f}s ({elapsed/len(rows)*1000:.0f}ms/memory)")
88
-
89
- conn.close()
67
+ try:
68
+ for table in ["stm_memories", "ltm_memories"]:
69
+ rows = conn.execute(f"SELECT id, content FROM {table}").fetchall()
70
+ if not rows:
71
+ print(f"\n{table}: empty, skipping")
72
+ continue
73
+
74
+ print(f"\n{table}: re-embedding {len(rows)} memories...")
75
+ t0 = time.time()
76
+
77
+ # Batch embed for speed
78
+ contents = [r[1] for r in rows]
79
+ ids = [r[0] for r in rows]
80
+
81
+ embeddings = list(model.embed(contents))
82
+
83
+ for mem_id, emb in zip(ids, embeddings):
84
+ blob = np.array(emb, dtype=np.float32).tobytes()
85
+ conn.execute(f"UPDATE {table} SET embedding = ? WHERE id = ?", (blob, mem_id))
86
+
87
+ conn.commit()
88
+ elapsed = time.time() - t0
89
+ print(f" Done: {len(rows)} memories in {elapsed:.1f}s ({elapsed/len(rows)*1000:.0f}ms/memory)")
90
+ finally:
91
+ conn.close()
90
92
 
91
93
  print("\nAfter upgrade:")
92
94
  verify()
@@ -21,10 +21,14 @@ def handle_backup_now() -> str:
21
21
  # Use SQLite backup API for consistency
22
22
  import sqlite3
23
23
  src_conn = sqlite3.connect(DB_PATH)
24
- dst_conn = sqlite3.connect(dest)
25
- src_conn.backup(dst_conn)
26
- dst_conn.close()
27
- src_conn.close()
24
+ try:
25
+ dst_conn = sqlite3.connect(dest)
26
+ try:
27
+ src_conn.backup(dst_conn)
28
+ finally:
29
+ dst_conn.close()
30
+ finally:
31
+ src_conn.close()
28
32
 
29
33
  size_kb = os.path.getsize(dest) / 1024
30
34
  _cleanup_old()
@@ -63,17 +67,25 @@ def handle_backup_restore(filename: str) -> str:
63
67
  safety = os.path.join(BACKUP_DIR, f"nexo-pre-restore-{time.strftime('%Y%m%d%H%M%S')}.db")
64
68
  import sqlite3
65
69
  src_conn = sqlite3.connect(DB_PATH)
66
- dst_conn = sqlite3.connect(safety)
67
- src_conn.backup(dst_conn)
68
- dst_conn.close()
69
- src_conn.close()
70
+ try:
71
+ dst_conn = sqlite3.connect(safety)
72
+ try:
73
+ src_conn.backup(dst_conn)
74
+ finally:
75
+ dst_conn.close()
76
+ finally:
77
+ src_conn.close()
70
78
 
71
79
  # Restore
72
80
  restore_conn = sqlite3.connect(src)
73
- target_conn = sqlite3.connect(DB_PATH)
74
- restore_conn.backup(target_conn)
75
- target_conn.close()
76
- restore_conn.close()
81
+ try:
82
+ target_conn = sqlite3.connect(DB_PATH)
83
+ try:
84
+ restore_conn.backup(target_conn)
85
+ finally:
86
+ target_conn.close()
87
+ finally:
88
+ restore_conn.close()
77
89
 
78
90
  # Invalidate shared connection so db.py reconnects to restored data
79
91
  import db
@@ -23,6 +23,7 @@ from db import (
23
23
  )
24
24
  from plugins.cortex import evaluate_cortex_state
25
25
  from plugins.guard import handle_guard_check
26
+ from protocol_settings import get_protocol_strictness
26
27
  from tools_sessions import handle_heartbeat
27
28
 
28
29
 
@@ -450,6 +451,18 @@ def handle_task_open(
450
451
 
451
452
  clean_type = task_type if task_type in {"answer", "analyze", "edit", "execute", "delegate"} else "answer"
452
453
  files_list = _parse_list(files)
454
+ protocol_strictness = get_protocol_strictness()
455
+ if protocol_strictness in {"strict", "learning"} and clean_type == "edit" and not files_list:
456
+ note = (
457
+ "Strict protocol mode requires explicit `files` for edit tasks."
458
+ if protocol_strictness == "strict"
459
+ else "Learning mode requires explicit `files` on edit tasks so NEXO can match the write against the open protocol task."
460
+ )
461
+ return json.dumps(
462
+ {"ok": False, "error": note, "protocol_strictness": protocol_strictness},
463
+ ensure_ascii=False,
464
+ indent=2,
465
+ )
453
466
  state = {
454
467
  "goal": clean_goal,
455
468
  "task_type": clean_type,
@@ -569,6 +582,7 @@ def handle_task_open(
569
582
  "session_id": sid,
570
583
  "goal": clean_goal,
571
584
  "task_type": clean_type,
585
+ "protocol_strictness": protocol_strictness,
572
586
  "mode": cortex["mode"],
573
587
  "check_id": cortex["check_id"],
574
588
  "blocked_reason": cortex.get("blocked_reason"),
@@ -596,6 +610,7 @@ def handle_task_open(
596
610
  "must_change_log": must_change_log,
597
611
  "must_learning_if_corrected": must_learning_if_corrected,
598
612
  "must_write_diary_on_close": must_write_diary_on_close,
613
+ "protocol_strictness": protocol_strictness,
599
614
  },
600
615
  "session_touch": heartbeat_result.splitlines()[0] if heartbeat_result else "",
601
616
  "open_debts": debts_created,