nexo-brain 2.6.15 → 2.6.16

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.
@@ -7,6 +7,7 @@ from cognitive._core import (
7
7
  _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
8
8
  redact_secrets, extract_temporal_date, EMBEDDING_DIM,
9
9
  PE_GATE_REJECT, PE_GATE_REFINE, _gate_stats,
10
+ initial_memory_profile, rehearsal_profile_update,
10
11
  )
11
12
 
12
13
 
@@ -76,6 +77,7 @@ def ingest(
76
77
  vec = embed(clean_content)
77
78
  blob = _array_to_blob(vec)
78
79
  temporal = extract_temporal_date(content)
80
+ stability, difficulty = initial_memory_profile(source_type, store="stm")
79
81
 
80
82
  # Auto-pin: corrections and blocking learnings get pinned (zero decay, +0.2 boost)
81
83
  # This ensures user's corrections NEVER fade away
@@ -94,9 +96,9 @@ def ingest(
94
96
  db.commit()
95
97
  # Now actually store in STM
96
98
  cur2 = db.execute(
97
- """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state)
98
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
99
- (clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle)
99
+ """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state, stability, difficulty)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
101
+ (clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle, stability, difficulty)
100
102
  )
101
103
  db.commit()
102
104
  _hnsw_notify_insert("stm", cur2.lastrowid, vec)
@@ -105,9 +107,9 @@ def ingest(
105
107
  # skip_quarantine = direct STM (backward compatibility)
106
108
  if skip_quarantine:
107
109
  cur = db.execute(
108
- """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state)
109
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
110
- (clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle)
110
+ """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, temporal_date, lifecycle_state, stability, difficulty)
111
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
112
+ (clean_content, blob, source_type, source_id, source_title, domain, was_redacted, temporal, _pin_lifecycle, stability, difficulty)
111
113
  )
112
114
  db.commit()
113
115
  _hnsw_notify_insert("stm", cur.lastrowid, vec)
@@ -246,10 +248,11 @@ def ingest_to_ltm(
246
248
  was_redacted = 1 if clean_content != content else 0
247
249
  vec = embed(clean_content)
248
250
  blob = _array_to_blob(vec)
251
+ stability, difficulty = initial_memory_profile(source_type, store="ltm")
249
252
  cur = db.execute(
250
- """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, redaction_applied)
251
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
252
- (clean_content, blob, source_type, source_id, source_title, domain, tags, was_redacted)
253
+ """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, redaction_applied, stability, difficulty)
254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
255
+ (clean_content, blob, source_type, source_id, source_title, domain, tags, was_redacted, stability, difficulty)
253
256
  )
254
257
  db.commit()
255
258
  return cur.lastrowid
@@ -267,10 +270,11 @@ def ingest_sensory(
267
270
  vec = embed(clean_content)
268
271
  blob = _array_to_blob(vec)
269
272
  ts = created_at or datetime.utcnow().isoformat()
273
+ stability, difficulty = initial_memory_profile("sensory", store="stm")
270
274
  cur = db.execute(
271
- """INSERT INTO stm_memories (content, embedding, source_type, source_id, domain, created_at, redaction_applied)
272
- VALUES (?, ?, 'sensory', ?, ?, ?, ?)""",
273
- (clean_content, blob, source_id, domain, ts, was_redacted)
275
+ """INSERT INTO stm_memories (content, embedding, source_type, source_id, domain, created_at, redaction_applied, stability, difficulty)
276
+ VALUES (?, ?, 'sensory', ?, ?, ?, ?, ?, ?)""",
277
+ (clean_content, blob, source_id, domain, ts, was_redacted, stability, difficulty)
274
278
  )
275
279
  db.commit()
276
280
  return cur.lastrowid
@@ -386,6 +390,12 @@ def _refine_memory(match_info: dict, new_content: str) -> int:
386
390
  db = _get_db()
387
391
  table = "stm_memories" if match_info["store"] == "stm" else "ltm_memories"
388
392
  memory_id = match_info["id"]
393
+ profile_row = db.execute(
394
+ f"SELECT stability, difficulty FROM {table} WHERE id = ?",
395
+ (memory_id,),
396
+ ).fetchone()
397
+ current_stability = profile_row["stability"] if profile_row else 1.0
398
+ current_difficulty = profile_row["difficulty"] if profile_row else 0.5
389
399
 
390
400
  # Check word-level diff to avoid appending near-identical text
391
401
  existing_words = set(match_info["content"].lower().split())
@@ -395,10 +405,16 @@ def _refine_memory(match_info: dict, new_content: str) -> int:
395
405
  if len(unique_new) < 3:
396
406
  # Almost no new words -- just strengthen the existing memory
397
407
  now = datetime.utcnow().isoformat()
408
+ new_stability, new_difficulty = rehearsal_profile_update(
409
+ current_stability,
410
+ current_difficulty,
411
+ score=0.7,
412
+ refinement=True,
413
+ )
398
414
  db.execute(
399
415
  f"UPDATE {table} SET strength = MIN(1.0, strength + 0.1), "
400
- f"access_count = access_count + 1, last_accessed = ? WHERE id = ?",
401
- (now, memory_id)
416
+ f"access_count = access_count + 1, last_accessed = ?, stability = ?, difficulty = ? WHERE id = ?",
417
+ (now, new_stability, new_difficulty, memory_id)
402
418
  )
403
419
  db.commit()
404
420
  return memory_id
@@ -408,11 +424,17 @@ def _refine_memory(match_info: dict, new_content: str) -> int:
408
424
  new_vec = embed(merged_content)
409
425
  new_blob = _array_to_blob(new_vec)
410
426
  now = datetime.utcnow().isoformat()
427
+ new_stability, new_difficulty = rehearsal_profile_update(
428
+ current_stability,
429
+ current_difficulty,
430
+ score=0.82,
431
+ refinement=True,
432
+ )
411
433
 
412
434
  db.execute(
413
435
  f"UPDATE {table} SET content = ?, embedding = ?, strength = MIN(1.0, strength + 0.15), "
414
- f"access_count = access_count + 1, last_accessed = ? WHERE id = ?",
415
- (merged_content, new_blob, now, memory_id)
436
+ f"access_count = access_count + 1, last_accessed = ?, stability = ?, difficulty = ? WHERE id = ?",
437
+ (merged_content, new_blob, now, new_stability, new_difficulty, memory_id)
416
438
  )
417
439
  db.commit()
418
440
  return memory_id
@@ -614,10 +636,10 @@ def process_quarantine() -> dict:
614
636
  if should_promote:
615
637
  # Promote to STM
616
638
  cur = db.execute(
617
- """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied)
618
- VALUES (?, ?, ?, ?, ?, ?, 0)""",
639
+ """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, stability, difficulty)
640
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)""",
619
641
  (content, row["embedding"], row["source_type"], row["source_id"],
620
- row["source_title"], row["domain"])
642
+ row["source_title"], row["domain"], *initial_memory_profile(row["source_type"], store="stm"))
621
643
  )
622
644
  db.execute(
623
645
  "UPDATE quarantine SET status = 'promoted', promoted_at = datetime('now'), confidence = 1.0 WHERE id = ?",
@@ -689,10 +711,10 @@ def quarantine_promote(quarantine_id: int) -> str:
689
711
 
690
712
  # Insert into STM
691
713
  db.execute(
692
- """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied)
693
- VALUES (?, ?, ?, ?, ?, ?, 0)""",
714
+ """INSERT INTO stm_memories (content, embedding, source_type, source_id, source_title, domain, redaction_applied, stability, difficulty)
715
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)""",
694
716
  (row["content"], row["embedding"], row["source_type"], row["source_id"],
695
- row["source_title"], row["domain"])
717
+ row["source_title"], row["domain"], *initial_memory_profile(row["source_type"], store="stm"))
696
718
  )
697
719
  db.execute(
698
720
  "UPDATE quarantine SET status = 'promoted', promoted_at = datetime('now'), confidence = 1.0 WHERE id = ?",
@@ -406,6 +406,10 @@ def get_stats() -> dict:
406
406
 
407
407
  avg_stm = db.execute("SELECT AVG(strength) FROM stm_memories WHERE lifecycle_state IN ('active', 'pinned') AND promoted_to_ltm = 0").fetchone()[0] or 0.0
408
408
  avg_ltm = db.execute("SELECT AVG(strength) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0] or 0.0
409
+ avg_stm_stability = db.execute("SELECT AVG(stability) FROM stm_memories WHERE lifecycle_state IN ('active', 'pinned') AND promoted_to_ltm = 0").fetchone()[0] or 0.0
410
+ avg_ltm_stability = db.execute("SELECT AVG(stability) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0] or 0.0
411
+ avg_stm_difficulty = db.execute("SELECT AVG(difficulty) FROM stm_memories WHERE lifecycle_state IN ('active', 'pinned') AND promoted_to_ltm = 0").fetchone()[0] or 0.0
412
+ avg_ltm_difficulty = db.execute("SELECT AVG(difficulty) FROM ltm_memories WHERE is_dormant = 0").fetchone()[0] or 0.0
409
413
 
410
414
  total_retrievals = db.execute("SELECT COUNT(*) FROM retrieval_log").fetchone()[0]
411
415
  avg_retrieval_score = db.execute("SELECT AVG(top_score) FROM retrieval_log").fetchone()[0] or 0.0
@@ -428,6 +432,10 @@ def get_stats() -> dict:
428
432
  "ltm_dormant": ltm_dormant,
429
433
  "avg_stm_strength": round(avg_stm, 3),
430
434
  "avg_ltm_strength": round(avg_ltm, 3),
435
+ "avg_stm_stability": round(avg_stm_stability, 3),
436
+ "avg_ltm_stability": round(avg_ltm_stability, 3),
437
+ "avg_stm_difficulty": round(avg_stm_difficulty, 3),
438
+ "avg_ltm_difficulty": round(avg_ltm_difficulty, 3),
431
439
  "total_retrievals": total_retrievals,
432
440
  "avg_retrieval_score": round(avg_retrieval_score, 3),
433
441
  "top_domains_stm": [(r["domain"], r["cnt"]) for r in top_domains_stm],
@@ -1,9 +1,14 @@
1
1
  """NEXO Cognitive — Search, retrieval, ranking."""
2
2
  import math
3
+ import re
3
4
  import sqlite3
4
5
  import numpy as np
5
6
  from datetime import datetime
6
- from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob, _get_model, _get_reranker, rerank_results, EMBEDDING_DIM
7
+ from cognitive._core import (
8
+ _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
9
+ _get_model, _get_reranker, rerank_results, EMBEDDING_DIM,
10
+ rehearsal_profile_update,
11
+ )
7
12
 
8
13
  def bm25_search(query_text: str, stores: str = "both", top_k: int = 20,
9
14
  source_type_filter: str = "") -> list[dict]:
@@ -151,6 +156,10 @@ _HISTORICAL_CUES = frozenset({
151
156
  "cuando", "hace", "meses", "año", "anterior", "antes",
152
157
  })
153
158
 
159
+ _EXACT_LOOKUP_RE = re.compile(
160
+ r"(/|\\|::|\.[A-Za-z0-9]+|#L\d+|line \d+|error[: ]|exception|traceback|0x[0-9a-fA-F]+|[A-Z]{2,}-\d+)"
161
+ )
162
+
154
163
 
155
164
  def _apply_temporal_boost(results: list[dict], query_text: str) -> list[dict]:
156
165
  """Apply bounded temporal boost to retrieval results.
@@ -209,6 +218,42 @@ def _apply_temporal_boost(results: list[dict], query_text: str) -> list[dict]:
209
218
  return results
210
219
 
211
220
 
221
+ def _looks_like_exact_lookup(query_text: str) -> bool:
222
+ query = str(query_text or "").strip()
223
+ if not query:
224
+ return False
225
+ lowered = query.lower()
226
+ if _EXACT_LOOKUP_RE.search(query):
227
+ return True
228
+ if any(token in lowered for token in ("file ", "path ", "port ", "localhost", "grep ", "rg ", "exact", "literal")):
229
+ return True
230
+ if any(ch in query for ch in ('"', "'", "`", "=", "[", "]")):
231
+ return True
232
+ return False
233
+
234
+
235
+ def _auto_use_hyde(query_text: str, source_type_filter: str = "") -> bool:
236
+ query = str(query_text or "").strip()
237
+ if not query or source_type_filter:
238
+ return False
239
+ if len(query) < 18 or _looks_like_exact_lookup(query):
240
+ return False
241
+ intent = _classify_query_intent(query)
242
+ return intent in {"howto", "definition", "reasoning"} or (intent == "lookup" and len(query.split()) >= 4)
243
+
244
+
245
+ def _auto_spreading_depth(query_text: str, source_type_filter: str = "") -> int:
246
+ query = str(query_text or "").strip().lower()
247
+ if not query or source_type_filter or _looks_like_exact_lookup(query):
248
+ return 0
249
+ intent = _classify_query_intent(query)
250
+ if intent in {"howto", "definition", "reasoning"}:
251
+ return 1
252
+ if any(connector in query for connector in (" and ", " because ", " related ", " connect ", " why ", " how ")):
253
+ return 1
254
+ return 0
255
+
256
+
212
257
  # ============================================================================
213
258
  # FEATURE 0.5: Knowledge Graph Boost
214
259
  # Memories connected to more KG nodes (files, areas, other learnings) are
@@ -614,9 +659,18 @@ def _rehearse_results(results: list[dict], skip_ids: set = None):
614
659
  if (r["store"], r["id"]) in skip:
615
660
  continue
616
661
  table = "stm_memories" if r["store"] == "stm" else "ltm_memories"
662
+ current = db.execute(
663
+ f"SELECT stability, difficulty FROM {table} WHERE id = ?",
664
+ (r["id"],),
665
+ ).fetchone()
666
+ new_stability, new_difficulty = rehearsal_profile_update(
667
+ current["stability"] if current else 1.0,
668
+ current["difficulty"] if current else 0.5,
669
+ score=r.get("score", 0.6),
670
+ )
617
671
  db.execute(
618
- f"UPDATE {table} SET strength = MIN(1.0, strength + 0.08), access_count = access_count + 1, last_accessed = ? WHERE id = ?",
619
- (now, r["id"])
672
+ f"UPDATE {table} SET strength = MIN(1.0, strength + 0.08), access_count = access_count + 1, last_accessed = ?, stability = ?, difficulty = ? WHERE id = ?",
673
+ (now, new_stability, new_difficulty, r["id"])
620
674
  )
621
675
  db.commit()
622
676
 
@@ -630,18 +684,18 @@ def search(
630
684
  rehearse: bool = True,
631
685
  source_type_filter: str = "",
632
686
  include_archived: bool = False,
633
- use_hyde: bool = False,
687
+ use_hyde: bool | None = None,
634
688
  hybrid: bool = True,
635
689
  hybrid_alpha: float = 0.6,
636
- spreading_depth: int = 0,
690
+ spreading_depth: int | None = None,
637
691
  decompose: bool = True,
638
692
  exclude_dreams: bool = True,
639
693
  ) -> list[dict]:
640
694
  """Full vector search across STM and/or LTM with rehearsal and dormant reactivation.
641
695
 
642
696
  Args:
643
- use_hyde: If True, use HyDE query expansion for richer embedding (default False)
644
- spreading_depth: If >0, fetch co-activated neighbors and boost their scores (default 0)
697
+ use_hyde: If True, force HyDE on; if False, force it off; if None, auto-enable for conceptual queries.
698
+ spreading_depth: If >0, fetch co-activated neighbors and boost their scores. If None, use a shallow auto-default for multi-hop queries.
645
699
  exclude_dreams: If True (default), exclude dream_insight memories from results.
646
700
  Dream insights are 21% of LTM and dilute search precision.
647
701
  Set to False only when explicitly looking for cross-domain patterns.
@@ -677,6 +731,8 @@ def search(
677
731
  return merged
678
732
 
679
733
  db = _get_db()
734
+ resolved_use_hyde = _auto_use_hyde(query_text, source_type_filter) if use_hyde is None else bool(use_hyde)
735
+ resolved_spreading_depth = _auto_spreading_depth(query_text, source_type_filter) if spreading_depth is None else max(0, int(spreading_depth))
680
736
 
681
737
  # Detect temporal queries — boost results with temporal_date
682
738
  _temporal_keywords = {"when", "date", "time", "first", "last", "before", "after",
@@ -684,7 +740,7 @@ def search(
684
740
  query_lower = query_text.lower().split()
685
741
  is_temporal_query = bool(_temporal_keywords & set(query_lower))
686
742
 
687
- if use_hyde:
743
+ if resolved_use_hyde:
688
744
  query_vec = hyde_expand_query(query_text)
689
745
  else:
690
746
  query_vec = embed(query_text)
@@ -855,9 +911,9 @@ def search(
855
911
 
856
912
  # Spreading activation: boost co-activated neighbors (Feature 2)
857
913
  co_activation_applied = False
858
- if spreading_depth > 0 and results:
914
+ if resolved_spreading_depth > 0 and results:
859
915
  memory_ids = [(r["store"], r["id"]) for r in results]
860
- neighbor_boosts = _get_co_activated_neighbors(memory_ids, depth=spreading_depth)
916
+ neighbor_boosts = _get_co_activated_neighbors(memory_ids, depth=resolved_spreading_depth)
861
917
 
862
918
  if neighbor_boosts:
863
919
  co_activation_applied = True
@@ -911,7 +967,7 @@ def search(
911
967
  reactivated = r.get("reactivated", False)
912
968
 
913
969
  ranking_desc = "semantic_similarity"
914
- if use_hyde:
970
+ if resolved_use_hyde:
915
971
  ranking_desc = "hyde_centroid_similarity"
916
972
  parts = [f"Ranked #{rank}: {ranking_desc}={score:.3f}"]
917
973
  parts.append(f"store={store}, strength={strength:.2f}, accesses={access_count}")
@@ -919,6 +975,10 @@ def search(
919
975
  parts.append(f"kg_boost=+{r['kg_boost']:.3f} ({r.get('kg_connections', 0)} edges)")
920
976
  if r.get("co_activation_boost"):
921
977
  parts.append(f"co_activation_boost=+{r['co_activation_boost']:.3f}")
978
+ if use_hyde is None and resolved_use_hyde:
979
+ parts.append("hyde=auto")
980
+ if spreading_depth is None and resolved_spreading_depth > 0:
981
+ parts.append(f"spreading=auto:{resolved_spreading_depth}")
922
982
  if created:
923
983
  parts.append(f"created={created[:10]}")
924
984
  if tags:
@@ -28,6 +28,8 @@ _PARENT = str(Path(__file__).resolve().parent.parent)
28
28
  if _PARENT not in sys.path:
29
29
  sys.path.insert(0, _PARENT)
30
30
 
31
+ from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
32
+
31
33
  app = FastAPI(title="NEXO Brain Dashboard", version="3.0.0")
32
34
 
33
35
  TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
@@ -137,7 +139,7 @@ class ChatMessage(BaseModel):
137
139
 
138
140
  def _cognitive_db():
139
141
  """Direct connection to cognitive.db."""
140
- nexo_home = os.environ.get("NEXO_HOME", str(Path.home() / "claude"))
142
+ nexo_home = os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))
141
143
  db_path = Path(nexo_home) / "data" / "cognitive.db"
142
144
  conn = sqlite3.connect(str(db_path))
143
145
  conn.row_factory = sqlite3.Row
@@ -145,7 +147,8 @@ def _cognitive_db():
145
147
 
146
148
  def _email_db():
147
149
  """Direct connection to nexo-email.db."""
148
- db_path = Path.home() / "claude" / "nexo-email" / "nexo-email.db"
150
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
151
+ db_path = nexo_home / "nexo-email" / "nexo-email.db"
149
152
  if not db_path.exists():
150
153
  return None
151
154
  conn = sqlite3.connect(str(db_path))
@@ -420,7 +423,7 @@ async def api_sessions(limit: int = Query(10, ge=1, le=50)):
420
423
  conn = db.get_db()
421
424
  # Active sessions (from sessions table, not diaries)
422
425
  active_rows = conn.execute(
423
- "SELECT sid as session_id, task, last_update_epoch, claude_session_id "
426
+ "SELECT sid as session_id, task, last_update_epoch, claude_session_id, external_session_id, session_client "
424
427
  "FROM sessions WHERE last_update_epoch > (strftime('%s','now') - 900) "
425
428
  "ORDER BY last_update_epoch DESC"
426
429
  ).fetchall()
@@ -716,7 +719,7 @@ async def api_ops_move(body: MoveRequest):
716
719
 
717
720
  @app.post("/api/ops/execute/{fid}")
718
721
  async def api_ops_execute(fid: str):
719
- """Execute a followup by opening Terminal with claude command."""
722
+ """Execute a followup by opening Terminal with the configured NEXO client."""
720
723
  db = _db()
721
724
  conn = db.get_db()
722
725
  row = conn.execute("SELECT * FROM followups WHERE id = ?", (fid,)).fetchone()
@@ -734,9 +737,13 @@ async def api_ops_execute(fid: str):
734
737
  tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", prefix="nexo-followup-", delete=False)
735
738
  tmp.write(fid)
736
739
  tmp.close()
737
- # The claude command reads the followup ID from the temp file — no shell interpolation of description
738
- claude_cmd = f'claude \\"NEXO: execute followup from file $(cat {tmp.name})\\"'
739
- script = f'tell application "Terminal" to do script "{claude_cmd}"'
740
+ # The selected terminal client reads the followup ID from the temp file — no shell interpolation of description
741
+ try:
742
+ _, shell_cmd = build_followup_terminal_shell_command(tmp.name)
743
+ except AgentRunnerError as exc:
744
+ return JSONResponse({"error": str(exc)}, status_code=503)
745
+ escaped = shell_cmd.replace("\\", "\\\\").replace('"', '\\"')
746
+ script = f'tell application "Terminal" to do script "{escaped}"'
740
747
  subprocess.Popen(["osascript", "-e", script])
741
748
  return {"success": True, "followup_id": fid}
742
749
 
@@ -1388,7 +1395,7 @@ async def api_credentials():
1388
1395
 
1389
1396
  @app.get("/api/backups")
1390
1397
  async def api_backups():
1391
- nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / "claude")))
1398
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
1392
1399
  backup_dir = nexo_home / "backups"
1393
1400
  data_dir = nexo_home / "data"
1394
1401
  backups = []
package/src/db/_schema.py CHANGED
@@ -256,6 +256,15 @@ def _m13_claude_session_id(conn):
256
256
  conn.commit()
257
257
 
258
258
 
259
+ def _m21_external_session_fields(conn):
260
+ """Generalize session linkage beyond Claude-specific naming."""
261
+ _migrate_add_column(conn, "sessions", "external_session_id", "TEXT DEFAULT ''")
262
+ _migrate_add_column(conn, "sessions", "session_client", "TEXT DEFAULT ''")
263
+ _migrate_add_index(conn, "idx_sessions_external_sid", "sessions", "external_session_id")
264
+ _migrate_add_index(conn, "idx_sessions_client", "sessions", "session_client")
265
+ conn.commit()
266
+
267
+
259
268
  def _m14_learnings_priority_weight(conn):
260
269
  """Add priority, weight, and guard usage tracking to learnings + followup priority."""
261
270
  _migrate_add_column(conn, "learnings", "priority", "TEXT DEFAULT 'medium'")
@@ -445,6 +454,7 @@ MIGRATIONS = [
445
454
  (18, "skills_steps_column", _m18_skills_steps),
446
455
  (19, "skills_v2", _m19_skills_v2),
447
456
  (20, "personal_scripts_registry", _m20_personal_scripts_registry),
457
+ (21, "external_session_fields", _m21_external_session_fields),
448
458
  ]
449
459
 
450
460
 
@@ -34,18 +34,26 @@ def _validate_sid(sid: str) -> str:
34
34
  raise ValueError(f"Invalid SID format: {sid[:80]}")
35
35
 
36
36
 
37
- def register_session(sid: str, task: str, claude_session_id: str = "") -> dict:
37
+ def register_session(
38
+ sid: str,
39
+ task: str,
40
+ claude_session_id: str = "",
41
+ *,
42
+ external_session_id: str = "",
43
+ session_client: str = "",
44
+ ) -> dict:
38
45
  """Register or re-register a session."""
39
46
  sid = _validate_sid(sid)
40
47
  conn = get_db()
41
48
  now = now_epoch()
49
+ linked_session_id = (external_session_id or claude_session_id or "").strip()
42
50
  conn.execute(
43
- "INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id) "
44
- "VALUES (?, ?, ?, ?, ?, ?)",
45
- (sid, task, now, now, local_time_str(), claude_session_id)
51
+ "INSERT OR REPLACE INTO sessions (sid, task, started_epoch, last_update_epoch, local_time, claude_session_id, external_session_id, session_client) "
52
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
53
+ (sid, task, now, now, local_time_str(), linked_session_id, linked_session_id, (session_client or "").strip())
46
54
  )
47
55
  conn.commit()
48
- return {"sid": sid, "task": task}
56
+ return {"sid": sid, "task": task, "external_session_id": linked_session_id, "session_client": (session_client or "").strip()}
49
57
 
50
58
 
51
59
  def update_session(sid: str, task: str | None) -> dict:
@@ -320,4 +328,3 @@ def _expire_old_questions(conn: sqlite3.Connection):
320
328
  (cutoff,)
321
329
  )
322
330
 
323
-
@@ -9,6 +9,7 @@ import plistlib
9
9
  import subprocess
10
10
  import sys
11
11
  import time
12
+ import tomllib
12
13
  from pathlib import Path
13
14
 
14
15
  from client_preferences import (
@@ -39,6 +40,32 @@ OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
39
40
  SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
40
41
 
41
42
 
43
+ def _codex_bootstrap_config_status() -> dict:
44
+ path = Path.home() / ".codex" / "config.toml"
45
+ if not path.is_file():
46
+ return {"exists": False, "path": str(path), "bootstrap_managed": False}
47
+ try:
48
+ payload = tomllib.loads(path.read_text())
49
+ except Exception as exc:
50
+ return {
51
+ "exists": True,
52
+ "path": str(path),
53
+ "bootstrap_managed": False,
54
+ "error": str(exc),
55
+ }
56
+ managed = bool(payload.get("nexo", {}).get("codex", {}).get("bootstrap_managed"))
57
+ initial_messages = payload.get("initial_messages", [])
58
+ has_initial_messages = bool(initial_messages)
59
+ return {
60
+ "exists": True,
61
+ "path": str(path),
62
+ "bootstrap_managed": managed,
63
+ "has_initial_messages": has_initial_messages,
64
+ "model": str(payload.get("model", "") or ""),
65
+ "reasoning_effort": str(payload.get("model_reasoning_effort", "") or ""),
66
+ }
67
+
68
+
42
69
  def _file_age_seconds(path: Path) -> float | None:
43
70
  """Return file age in seconds, or None if not found."""
44
71
  try:
@@ -1054,13 +1081,41 @@ def check_client_bootstrap_parity(fix: bool = False) -> DoctorCheck:
1054
1081
  f"`{client_key}` bootstrap version {info.get('version') or 'unknown'} != template {info.get('template_version')}"
1055
1082
  )
1056
1083
  repair_plan.append("Refresh bootstrap files from the current NEXO templates")
1084
+ if client_key == "codex":
1085
+ codex_config = _codex_bootstrap_config_status()
1086
+ if codex_config.get("error"):
1087
+ status = "degraded"
1088
+ severity = "warn"
1089
+ evidence.append(f"codex config TOML invalid at {codex_config.get('path')}: {codex_config.get('error')}")
1090
+ repair_plan.append("Repair ~/.codex/config.toml so NEXO can manage Codex bootstrap and model defaults")
1091
+ elif codex_config.get("exists") and not codex_config.get("bootstrap_managed"):
1092
+ status = "degraded"
1093
+ severity = "warn"
1094
+ evidence.append(f"codex config missing managed bootstrap injection at {codex_config.get('path')}")
1095
+ repair_plan.append("Run `nexo clients sync` or `nexo update` so plain Codex sessions inherit the NEXO bootstrap")
1096
+ elif codex_config.get("exists"):
1097
+ evidence.append(
1098
+ "codex config bootstrap managed"
1099
+ + (
1100
+ f" ({codex_config.get('model') or 'default'}, {codex_config.get('reasoning_effort') or 'default'})"
1101
+ )
1102
+ )
1057
1103
 
1058
1104
  if fix and status != "healthy":
1059
- sync_enabled_bootstraps(
1060
- nexo_home=NEXO_HOME,
1061
- user_home=Path.home(),
1062
- preferences=prefs,
1063
- )
1105
+ try:
1106
+ from client_sync import sync_all_clients
1107
+ sync_all_clients(
1108
+ nexo_home=NEXO_HOME,
1109
+ runtime_root=NEXO_CODE,
1110
+ user_home=Path.home(),
1111
+ preferences=prefs,
1112
+ )
1113
+ except Exception:
1114
+ sync_enabled_bootstraps(
1115
+ nexo_home=NEXO_HOME,
1116
+ user_home=Path.home(),
1117
+ preferences=prefs,
1118
+ )
1064
1119
  post = check_client_bootstrap_parity(fix=False)
1065
1120
  if post.status == "healthy":
1066
1121
  post.fixed = True
@@ -113,8 +113,8 @@ conn.row_factory = sqlite3.Row
113
113
  row = None
114
114
  if session_id and session_id != 'global':
115
115
  row = conn.execute(
116
- 'SELECT sid, task FROM sessions WHERE claude_session_id = ? LIMIT 1',
117
- (session_id,)
116
+ 'SELECT sid, task FROM sessions WHERE external_session_id = ? OR claude_session_id = ? LIMIT 1',
117
+ (session_id, session_id)
118
118
  ).fetchone()
119
119
 
120
120
  # Fallback: most recent active session
@@ -32,7 +32,7 @@ DB="$NEXO_HOME/data/nexo.db"
32
32
  mkdir -p "$NEXO_HOME/data"
33
33
  [ -f "$DB" ] || exit 0
34
34
 
35
- NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE claude_session_id = '${CLAUDE_SID}' AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
35
+ NEXO_SID=$(sqlite3 "$DB" "SELECT sid FROM sessions WHERE (external_session_id = '${CLAUDE_SID}' OR claude_session_id = '${CLAUDE_SID}') AND last_update_epoch > (strftime('%s','now') - 900) ORDER BY last_update_epoch DESC LIMIT 1;" 2>/dev/null)
36
36
  [ -z "$NEXO_SID" ] && exit 0
37
37
 
38
38
  # 5. Check inbox — messages addressed to this session or broadcast
@@ -19,8 +19,8 @@ def handle_cognitive_retrieve(
19
19
  source_type: str = "",
20
20
  domain: str = "",
21
21
  include_archived: bool = False,
22
- use_hyde: bool = False,
23
- spreading_depth: int = 0,
22
+ use_hyde: bool | None = None,
23
+ spreading_depth: int | None = None,
24
24
  ) -> str:
25
25
  """RAG query over cognitive memory (STM + LTM). Triggers rehearsal on retrieved memories.
26
26
 
@@ -32,8 +32,8 @@ def handle_cognitive_retrieve(
32
32
  source_type: Filter by source type e.g. "change", "learning", "diary" (default: all)
33
33
  domain: Filter by domain e.g. "project-a", "shopify" (default: all)
34
34
  include_archived: If True, also search archived memories (default False)
35
- use_hyde: If True, use HyDE query expansion embeds 3-5 query variants and searches with centroid. Better recall for conceptual queries. (default False)
36
- spreading_depth: If >0, boost co-activated neighbors (memories frequently retrieved together). 1=direct neighbors only. (default 0)
35
+ use_hyde: If True/False, force HyDE on/off. If omitted, NEXO auto-enables it for conceptual queries.
36
+ spreading_depth: If >0, boost co-activated neighbors directly. If omitted, NEXO may auto-enable shallow spreading for multi-hop queries.
37
37
  """
38
38
  if not query or not query.strip():
39
39
  return "ERROR: query is required."
@@ -57,10 +57,14 @@ def handle_cognitive_retrieve(
57
57
 
58
58
  formatted = cognitive.format_results(results)
59
59
  mode_parts = [f"stores={stores}", f"min_score={min_score}"]
60
- if use_hyde:
60
+ if use_hyde is True:
61
61
  mode_parts.append("hyde=ON")
62
- if spreading_depth > 0:
62
+ elif use_hyde is None:
63
+ mode_parts.append("hyde=AUTO")
64
+ if spreading_depth and spreading_depth > 0:
63
65
  mode_parts.append(f"spreading={spreading_depth}")
66
+ elif spreading_depth is None:
67
+ mode_parts.append("spreading=AUTO")
64
68
  header = f"COGNITIVE RETRIEVE — query: '{query}' | {len(results)} results ({', '.join(mode_parts)})\n\n"
65
69
  return header + formatted
66
70
 
@@ -76,6 +80,10 @@ def handle_cognitive_stats() -> str:
76
80
  f" LTM dormant: {stats['ltm_dormant']}",
77
81
  f" Avg STM strength: {stats['avg_stm_strength']:.3f}",
78
82
  f" Avg LTM strength: {stats['avg_ltm_strength']:.3f}",
83
+ f" Avg STM stability: {stats.get('avg_stm_stability', 0.0):.3f}",
84
+ f" Avg LTM stability: {stats.get('avg_ltm_stability', 0.0):.3f}",
85
+ f" Avg STM difficulty: {stats.get('avg_stm_difficulty', 0.0):.3f}",
86
+ f" Avg LTM difficulty: {stats.get('avg_ltm_difficulty', 0.0):.3f}",
79
87
  f" Total retrievals: {stats['total_retrievals']}",
80
88
  f" Avg retrieval score: {stats['avg_retrieval_score']:.3f}",
81
89
  ]