nexo-brain 2.6.15 → 2.6.17

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.
@@ -19,6 +19,8 @@ COGNITIVE_DB = os.path.join(_data_dir, "cognitive.db")
19
19
  EMBEDDING_DIM = 768
20
20
  LAMBDA_STM = 0.004126 # half-life = ln(2) / (7 * 24) ≈ 7 days
21
21
  LAMBDA_LTM = 0.000481 # half-life = ln(2) / (60 * 24) ≈ 60 days
22
+ DEFAULT_MEMORY_STABILITY = 1.0
23
+ DEFAULT_MEMORY_DIFFICULTY = 0.5
22
24
 
23
25
  # Prediction Error Gate thresholds
24
26
  PE_GATE_REJECT = 0.85 # similarity > this → reject (not novel enough)
@@ -145,6 +147,7 @@ def _get_db() -> sqlite3.Connection:
145
147
  _init_tables(_conn)
146
148
  _migrate_lifecycle(_conn)
147
149
  _migrate_co_activation(_conn)
150
+ _migrate_memory_personalization(_conn)
148
151
  _auto_migrate_embeddings(_conn)
149
152
  return _conn
150
153
 
@@ -192,6 +195,79 @@ def _migrate_co_activation(conn: sqlite3.Connection):
192
195
  conn.commit()
193
196
 
194
197
 
198
+ def clamp_memory_stability(value: float | int | str | None) -> float:
199
+ try:
200
+ numeric = float(value)
201
+ except (TypeError, ValueError):
202
+ numeric = DEFAULT_MEMORY_STABILITY
203
+ return max(0.6, min(3.0, numeric))
204
+
205
+
206
+ def clamp_memory_difficulty(value: float | int | str | None) -> float:
207
+ try:
208
+ numeric = float(value)
209
+ except (TypeError, ValueError):
210
+ numeric = DEFAULT_MEMORY_DIFFICULTY
211
+ return max(0.2, min(1.2, numeric))
212
+
213
+
214
+ def initial_memory_profile(source_type: str, *, store: str = "stm") -> tuple[float, float]:
215
+ source = str(source_type or "").strip().lower()
216
+ if source in {"learning", "decision", "feedback"}:
217
+ return 1.2 if store == "stm" else 1.4, 0.4
218
+ if source in {"dream_insight", "session_summary"}:
219
+ return 1.1 if store == "stm" else 1.25, 0.55
220
+ if source in {"sensory", "dialog"}:
221
+ return 0.9, 0.6
222
+ return DEFAULT_MEMORY_STABILITY, DEFAULT_MEMORY_DIFFICULTY
223
+
224
+
225
+ def personalize_decay_rate(base_lambda: float, *, stability: float, difficulty: float) -> float:
226
+ stability_factor = clamp_memory_stability(stability)
227
+ difficulty_factor = 0.75 + (clamp_memory_difficulty(difficulty) * 0.5)
228
+ return base_lambda * difficulty_factor / stability_factor
229
+
230
+
231
+ def rehearsal_profile_update(
232
+ stability: float,
233
+ difficulty: float,
234
+ score: float,
235
+ *,
236
+ refinement: bool = False,
237
+ ) -> tuple[float, float]:
238
+ stable = clamp_memory_stability(stability)
239
+ hard = clamp_memory_difficulty(difficulty)
240
+ score = max(0.0, min(1.0, float(score or 0.0)))
241
+
242
+ stability_gain = 0.03 + max(0.0, score - 0.45) * 0.12
243
+ if refinement:
244
+ stability_gain += 0.03
245
+ new_stability = clamp_memory_stability(stable + stability_gain)
246
+
247
+ target_difficulty = clamp_memory_difficulty(1.0 - (score * 0.8))
248
+ if refinement:
249
+ target_difficulty = clamp_memory_difficulty(target_difficulty + 0.05)
250
+ new_difficulty = clamp_memory_difficulty((hard * 0.82) + (target_difficulty * 0.18))
251
+ return new_stability, new_difficulty
252
+
253
+
254
+ def _migrate_memory_personalization(conn: sqlite3.Connection):
255
+ """Add per-memory stability and difficulty columns if they don't exist."""
256
+ for table in ("stm_memories", "ltm_memories"):
257
+ for col, col_type in [
258
+ ("stability", f"REAL DEFAULT {DEFAULT_MEMORY_STABILITY}"),
259
+ ("difficulty", f"REAL DEFAULT {DEFAULT_MEMORY_DIFFICULTY}"),
260
+ ]:
261
+ try:
262
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {col_type}")
263
+ conn.commit()
264
+ except sqlite3.OperationalError as e:
265
+ if "duplicate column" in str(e).lower():
266
+ pass
267
+ else:
268
+ raise
269
+
270
+
195
271
  def _auto_migrate_embeddings(conn: sqlite3.Connection):
196
272
  """Auto-detect old 384-dim embeddings and re-embed to 768-dim. Transparent to user."""
197
273
  try:
@@ -242,6 +318,8 @@ def _init_tables(conn: sqlite3.Connection):
242
318
  last_accessed TEXT DEFAULT (datetime('now')),
243
319
  access_count INTEGER DEFAULT 0,
244
320
  strength REAL DEFAULT 1.0,
321
+ stability REAL DEFAULT 1.0,
322
+ difficulty REAL DEFAULT 0.5,
245
323
  promoted_to_ltm INTEGER DEFAULT 0
246
324
  );
247
325
 
@@ -257,6 +335,8 @@ def _init_tables(conn: sqlite3.Connection):
257
335
  last_accessed TEXT DEFAULT (datetime('now')),
258
336
  access_count INTEGER DEFAULT 0,
259
337
  strength REAL DEFAULT 1.0,
338
+ stability REAL DEFAULT 1.0,
339
+ difficulty REAL DEFAULT 0.5,
260
340
  is_dormant INTEGER DEFAULT 0,
261
341
  original_stm_id INTEGER,
262
342
  tags TEXT DEFAULT ''
@@ -2,7 +2,11 @@
2
2
  import math
3
3
  import numpy as np
4
4
  from datetime import datetime, timedelta
5
- from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob, LAMBDA_STM, LAMBDA_LTM, EMBEDDING_DIM
5
+ from cognitive._core import (
6
+ _get_db, embed, cosine_similarity, _blob_to_array, _array_to_blob,
7
+ LAMBDA_STM, LAMBDA_LTM, EMBEDDING_DIM,
8
+ initial_memory_profile, personalize_decay_rate,
9
+ )
6
10
 
7
11
 
8
12
  def _hnsw_invalidate():
@@ -48,20 +52,32 @@ def apply_decay(adaptive: bool = True):
48
52
  _protected_ltm.add(row["id"])
49
53
 
50
54
  # STM decay (skip pinned)
51
- rows = db.execute("SELECT id, last_accessed, strength FROM stm_memories WHERE promoted_to_ltm = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
55
+ rows = db.execute("SELECT id, last_accessed, strength, stability, difficulty FROM stm_memories WHERE promoted_to_ltm = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
52
56
  for row in rows:
53
57
  last = datetime.fromisoformat(row["last_accessed"])
54
58
  hours = (now - last).total_seconds() / 3600.0
55
- decay_rate = LAMBDA_STM * 0.25 if (adaptive and row["id"] in _protected_stm) else LAMBDA_STM
59
+ decay_rate = personalize_decay_rate(
60
+ LAMBDA_STM,
61
+ stability=row["stability"],
62
+ difficulty=row["difficulty"],
63
+ )
64
+ if adaptive and row["id"] in _protected_stm:
65
+ decay_rate *= 0.25
56
66
  new_strength = row["strength"] * math.exp(-decay_rate * hours)
57
67
  db.execute("UPDATE stm_memories SET strength = ? WHERE id = ?", (new_strength, row["id"]))
58
68
 
59
69
  # LTM decay (skip pinned)
60
- rows = db.execute("SELECT id, last_accessed, strength FROM ltm_memories WHERE is_dormant = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
70
+ rows = db.execute("SELECT id, last_accessed, strength, stability, difficulty FROM ltm_memories WHERE is_dormant = 0 AND (lifecycle_state IS NULL OR lifecycle_state != 'pinned')").fetchall()
61
71
  for row in rows:
62
72
  last = datetime.fromisoformat(row["last_accessed"])
63
73
  hours = (now - last).total_seconds() / 3600.0
64
- decay_rate = LAMBDA_LTM * 0.25 if (adaptive and row["id"] in _protected_ltm) else LAMBDA_LTM
74
+ decay_rate = personalize_decay_rate(
75
+ LAMBDA_LTM,
76
+ stability=row["stability"],
77
+ difficulty=row["difficulty"],
78
+ )
79
+ if adaptive and row["id"] in _protected_ltm:
80
+ decay_rate *= 0.25
65
81
  new_strength = row["strength"] * math.exp(-decay_rate * hours)
66
82
  if new_strength < 0.1:
67
83
  db.execute("UPDATE ltm_memories SET strength = ?, is_dormant = 1 WHERE id = ?", (new_strength, row["id"]))
@@ -101,10 +117,10 @@ def promote_stm_to_ltm():
101
117
  for row in rows:
102
118
  redacted = row["redaction_applied"] if "redaction_applied" in row.keys() else 0
103
119
  db.execute(
104
- """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, original_stm_id, redaction_applied)
105
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
120
+ """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, original_stm_id, redaction_applied, stability, difficulty)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
106
122
  (row["content"], row["embedding"], row["source_type"], row["source_id"],
107
- row["source_title"], row["domain"], row["id"], redacted)
123
+ row["source_title"], row["domain"], row["id"], redacted, row["stability"], row["difficulty"])
108
124
  )
109
125
  db.execute("UPDATE stm_memories SET promoted_to_ltm = 1 WHERE id = ?", (row["id"],))
110
126
  promoted += 1
@@ -322,12 +338,13 @@ def dream_cycle(max_insights: int = 50) -> dict:
322
338
 
323
339
  # Store as LTM with dream_insight tag
324
340
  cur = db.execute(
325
- """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, strength)
326
- VALUES (?, ?, 'dream_insight', ?, ?, ?, 'dream_insight', 0.5)""",
341
+ """INSERT INTO ltm_memories (content, embedding, source_type, source_id, source_title, domain, tags, strength, stability, difficulty)
342
+ VALUES (?, ?, 'dream_insight', ?, ?, ?, 'dream_insight', 0.5, ?, ?)""",
327
343
  (insight_content, blob,
328
344
  f"{mem_a['store']}:{mem_a['id']},{mem_b['store']}:{mem_b['id']}",
329
345
  f"Dream: {title_a[:30]} <-> {title_b[:30]}",
330
- domain_str)
346
+ domain_str,
347
+ *initial_memory_profile("dream_insight", store="ltm"))
331
348
  )
332
349
  insight_id = cur.lastrowid
333
350
 
@@ -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
-