nexo-brain 2.6.14 → 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.
@@ -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
  ]
@@ -16,6 +16,7 @@ import os
16
16
  import re
17
17
  import sqlite3
18
18
  import sys
19
+ from collections import Counter
19
20
  from datetime import datetime, timedelta
20
21
  from pathlib import Path
21
22
 
@@ -344,6 +345,172 @@ def collect_trust_score() -> list[dict]:
344
345
  )
345
346
 
346
347
 
348
+ def _parse_diary_created_at(value) -> datetime | None:
349
+ if value in (None, ""):
350
+ return None
351
+ try:
352
+ if isinstance(value, (int, float)) or (isinstance(value, str) and str(value).strip().isdigit()):
353
+ return datetime.fromtimestamp(float(value))
354
+ except Exception:
355
+ return None
356
+ try:
357
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00").replace("+00:00", ""))
358
+ except Exception:
359
+ return None
360
+
361
+
362
+ def _sample_evenly(rows: list[dict], limit: int) -> list[dict]:
363
+ if limit <= 0 or not rows:
364
+ return []
365
+ if len(rows) <= limit:
366
+ return list(rows)
367
+ if limit == 1:
368
+ return [rows[-1]]
369
+ step = (len(rows) - 1) / float(limit - 1)
370
+ indices = sorted({round(i * step) for i in range(limit)})
371
+ sampled = [rows[idx] for idx in indices]
372
+ i = 0
373
+ while len(sampled) < limit and i < len(rows):
374
+ if rows[i] not in sampled:
375
+ sampled.append(rows[i])
376
+ i += 1
377
+ return sampled[:limit]
378
+
379
+
380
+ def _compact_diary_row(row: dict) -> dict:
381
+ created = _parse_diary_created_at(row.get("created_at"))
382
+ return {
383
+ "session_id": row.get("session_id", ""),
384
+ "created_at": created.isoformat() if created else str(row.get("created_at", "")),
385
+ "domain": row.get("domain", "") or "",
386
+ "mental_state": row.get("mental_state", "") or "",
387
+ "summary": str(row.get("summary", "") or "")[:240],
388
+ "self_critique": str(row.get("self_critique", "") or "")[:240],
389
+ "source": row.get("source", "") or "",
390
+ }
391
+
392
+
393
+ def collect_long_horizon_context(
394
+ target_date: str,
395
+ *,
396
+ horizon_days: int = 60,
397
+ recent_days: int = 14,
398
+ max_diaries: int = 20,
399
+ max_sessions: int = 12,
400
+ ) -> dict:
401
+ """Build long-horizon context blending recent and older evidence.
402
+
403
+ Strategy:
404
+ - recent 70% from the last `recent_days`
405
+ - older 30% sampled evenly from the rest of the `horizon_days` window
406
+ """
407
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
408
+ horizon_start = target_day - timedelta(days=horizon_days)
409
+ recent_start = target_day - timedelta(days=recent_days)
410
+
411
+ diary_rows = safe_query(
412
+ NEXO_DB,
413
+ "SELECT session_id, created_at, summary, mental_state, domain, self_critique, source "
414
+ "FROM session_diary ORDER BY created_at ASC"
415
+ )
416
+ compact_diaries = []
417
+ for row in diary_rows:
418
+ created = _parse_diary_created_at(row.get("created_at"))
419
+ if not created:
420
+ continue
421
+ if not (horizon_start <= created < target_day):
422
+ continue
423
+ compact_diaries.append(_compact_diary_row(row))
424
+
425
+ recent_diaries = [row for row in compact_diaries if _parse_diary_created_at(row.get("created_at")) and _parse_diary_created_at(row.get("created_at")) >= recent_start]
426
+ older_diaries = [row for row in compact_diaries if row not in recent_diaries]
427
+ recent_quota = max(1, round(max_diaries * 0.7))
428
+ older_quota = max(0, max_diaries - recent_quota)
429
+ sampled_diaries = recent_diaries[-recent_quota:] + _sample_evenly(older_diaries, older_quota)
430
+ sampled_diaries.sort(key=lambda row: row.get("created_at", ""))
431
+
432
+ recurring_domains = Counter(row["domain"] for row in compact_diaries if row.get("domain"))
433
+ recurring_states = Counter(row["mental_state"] for row in compact_diaries if row.get("mental_state"))
434
+ recurring_critiques = Counter(row["self_critique"] for row in compact_diaries if row.get("self_critique"))
435
+
436
+ learning_rows = safe_query(
437
+ NEXO_DB,
438
+ "SELECT category, title, content, created_at, updated_at, reasoning, prevention, applies_to "
439
+ "FROM learnings ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120"
440
+ )
441
+ long_horizon_learnings = []
442
+ for row in learning_rows:
443
+ long_horizon_learnings.append({
444
+ "category": row.get("category", ""),
445
+ "title": str(row.get("title", "") or "")[:140],
446
+ "content": str(row.get("content", "") or "")[:260],
447
+ "reasoning": str(row.get("reasoning", "") or "")[:180],
448
+ "prevention": str(row.get("prevention", "") or "")[:180],
449
+ "applies_to": str(row.get("applies_to", "") or "")[:180],
450
+ "updated_at": str(row.get("updated_at", "") or row.get("created_at", "")),
451
+ })
452
+ long_horizon_learnings = long_horizon_learnings[:24]
453
+
454
+ transcript_candidates: list[dict] = []
455
+ transcript_files: list[tuple[str, Path]] = [
456
+ ("claude_code", path) for path in find_claude_session_files()
457
+ ] + [
458
+ ("codex", path) for path in find_codex_session_files()
459
+ ]
460
+ horizon_end = target_day
461
+ for client, path in transcript_files:
462
+ try:
463
+ modified = datetime.fromtimestamp(path.stat().st_mtime)
464
+ except OSError:
465
+ continue
466
+ if not (horizon_start <= modified < horizon_end):
467
+ continue
468
+ transcript_candidates.append({
469
+ "client": client,
470
+ "session_file": _session_identifier(client, path.name),
471
+ "modified": modified.isoformat(),
472
+ "session_path": str(path),
473
+ })
474
+ transcript_candidates.sort(key=lambda row: row["modified"])
475
+ recent_sessions = [row for row in transcript_candidates if datetime.fromisoformat(row["modified"]) >= recent_start]
476
+ older_sessions = [row for row in transcript_candidates if row not in recent_sessions]
477
+ recent_session_quota = max(1, round(max_sessions * 0.7))
478
+ older_session_quota = max(0, max_sessions - recent_session_quota)
479
+ sampled_sessions = recent_sessions[-recent_session_quota:] + _sample_evenly(older_sessions, older_session_quota)
480
+ sampled_sessions.sort(key=lambda row: row["modified"])
481
+
482
+ stale_followups = safe_query(
483
+ NEXO_DB,
484
+ "SELECT id, description, date, status, created_at, updated_at FROM followups "
485
+ "WHERE status NOT IN ('COMPLETED', 'CANCELLED') ORDER BY date ASC, created_at ASC LIMIT 50"
486
+ )
487
+ older_than_week = []
488
+ week_ago = target_day - timedelta(days=7)
489
+ for row in stale_followups:
490
+ created = _parse_diary_created_at(row.get("created_at"))
491
+ if created and created < week_ago:
492
+ older_than_week.append({
493
+ "id": row.get("id", ""),
494
+ "description": str(row.get("description", "") or "")[:180],
495
+ "date": row.get("date", ""),
496
+ "status": row.get("status", ""),
497
+ "created_at": created.isoformat(),
498
+ })
499
+
500
+ return {
501
+ "horizon_days": horizon_days,
502
+ "recent_window_days": recent_days,
503
+ "sample_strategy": "70% recent + 30% older evenly sampled",
504
+ "historical_diaries": sampled_diaries,
505
+ "historical_sessions": sampled_sessions,
506
+ "historical_learnings": long_horizon_learnings,
507
+ "recurring_domains": recurring_domains.most_common(8),
508
+ "recurring_mental_states": recurring_states.most_common(8),
509
+ "recurring_self_critiques": recurring_critiques.most_common(6),
510
+ "stale_followups": older_than_week[:12],
511
+ }
512
+
513
+
347
514
  # ── Discovery: scan NEXO_HOME for non-core content ───────────────────────
348
515
 
349
516
  CORE_DIRS = {"data", "operations", "logs", "coordination", "brain"}
@@ -550,6 +717,14 @@ def main():
550
717
  error_logs = collect_error_logs(target_date)
551
718
  print(f" Log files with errors: {len(error_logs)}")
552
719
 
720
+ print("[collect] Building long-horizon context...")
721
+ long_horizon = collect_long_horizon_context(target_date)
722
+ print(
723
+ " Long horizon: "
724
+ f"{len(long_horizon.get('historical_diaries', []))} diary samples, "
725
+ f"{len(long_horizon.get('historical_sessions', []))} session samples"
726
+ )
727
+
553
728
  # 5. Build per-session files + shared context
554
729
  date_dir = DEEP_SLEEP_DIR / target_date
555
730
  date_dir.mkdir(parents=True, exist_ok=True)
@@ -568,12 +743,17 @@ def main():
568
743
  shared_parts.append(format_section("TRUST SCORE HISTORY (7d)", trust_history))
569
744
  shared_parts.append(format_section("DISCOVERED NON-CORE CONTENT", extras))
570
745
  shared_parts.append(format_section("ERROR LOGS", error_logs))
746
+ shared_parts.append(format_section("LONG-HORIZON CONTEXT (60d blend)", long_horizon))
571
747
 
572
748
  shared_text = "\n".join(shared_parts)
573
749
  shared_file = date_dir / "shared-context.txt"
574
750
  shared_file.write_text(shared_text, encoding="utf-8")
575
751
  print(f" Shared context: {len(shared_text) / 1024:.0f} KB")
576
752
 
753
+ long_horizon_file = date_dir / "long-horizon-context.json"
754
+ long_horizon_file.write_text(json.dumps(long_horizon, indent=2, ensure_ascii=False), encoding="utf-8")
755
+ print(f" Long horizon JSON: {long_horizon_file.name}")
756
+
577
757
  # Individual session files
578
758
  session_files_written = []
579
759
  session_txt_map = {}
@@ -654,6 +834,7 @@ def main():
654
834
  "error_log_files": len(error_logs),
655
835
  "date_dir": str(date_dir),
656
836
  "shared_context_file": str(shared_file),
837
+ "long_horizon_file": str(long_horizon_file),
657
838
  "context_file": str(legacy_file),
658
839
  "total_size_bytes": total_size,
659
840
  }
@@ -12,12 +12,17 @@ Read the extractions file provided below. It contains per-session findings inclu
12
12
 
13
13
  Also read the runtime skill candidate file at `{{SKILL_RUNTIME_FILE}}`. It contains mature guide skills with repeated successful usage and candidates for automatic text→script evolution.
14
14
 
15
+ Also read the long-horizon file at `{{LONG_HORIZON_FILE}}`. It blends recent and older evidence from the last 60 days using a 70% recent / 30% older sample strategy. Use it to detect patterns that a single-day view would miss.
16
+
15
17
  Synthesize across all sessions:
16
18
 
17
19
  ### 1. Cross-Session Patterns
18
20
  - Same error appearing in multiple sessions (escalate confidence)
19
21
  - Same protocol violation repeated (systemic issue)
20
22
  - Related ideas mentioned across sessions (consolidate)
23
+ - Themes that recur across multiple weeks, not just today
24
+ - Cross-domain connections where an older learning or session sample explains a current issue
25
+ - Topics repeatedly mentioned over time but never formalized into a learning or followup
21
26
 
22
27
  ### 2. Morning Agenda
23
28
  Generate a prioritized agenda for the next morning:
@@ -91,6 +91,7 @@ def main():
91
91
 
92
92
  extractions_file = DEEP_SLEEP_DIR / f"{target_date}-extractions.json"
93
93
  context_file = DEEP_SLEEP_DIR / f"{target_date}-context.txt"
94
+ long_horizon_file = DEEP_SLEEP_DIR / target_date / "long-horizon-context.json"
94
95
 
95
96
  if not extractions_file.exists():
96
97
  print(f"[synthesize] No extractions file for {target_date}. Run extract.py first.")
@@ -129,6 +130,7 @@ def main():
129
130
  prompt = prompt_template.replace("{{EXTRACTIONS_FILE}}", str(extractions_file))
130
131
  prompt = prompt.replace("{{CONTEXT_FILE}}", str(context_file))
131
132
  prompt = prompt.replace("{{SKILL_RUNTIME_FILE}}", str(runtime_candidates_file))
133
+ prompt = prompt.replace("{{LONG_HORIZON_FILE}}", str(long_horizon_file))
132
134
 
133
135
  print(f"[synthesize] Phase 3: Synthesizing {total_findings} findings from {target_date}")
134
136
  print(f"[synthesize] Skill runtime candidates: {runtime_candidate_count}")
@@ -30,7 +30,7 @@ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
30
30
  DB="$NEXO_HOME/data/nexo.db"
31
31
  [ -f "$DB" ] || exit 0
32
32
 
33
- 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)
33
+ 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)
34
34
  [ -z "$NEXO_SID" ] && exit 0
35
35
 
36
36
  # 5. Check inbox — messages addressed to this session or broadcast
@@ -132,7 +132,7 @@ def analyze_entries(entries):
132
132
  "files_modified": files_modified,
133
133
  "self_critiques": self_critiques,
134
134
  "entry_count": len(entries),
135
- "claude_entries": sum(1 for e in entries if e.get("source") == "claude"),
135
+ "source_counts": dict(Counter(e.get("source") or "unknown" for e in entries)),
136
136
  }
137
137
 
138
138
 
@@ -188,7 +188,7 @@ def main():
188
188
  # Filter out pure hook entries (tool captures)
189
189
  session_entries = [
190
190
  e for e in entries
191
- if e.get("source") in ("claude", "hook-fallback")
191
+ if e.get("source") in ("claude", "hook-fallback", "codex", "codex_cli", "codex_cli_rs")
192
192
  or "tasks" in e
193
193
  ]
194
194
 
@@ -210,7 +210,7 @@ def main():
210
210
  reflection = {
211
211
  "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
212
212
  "entries_processed": analysis["entry_count"],
213
- "claude_entries": analysis["claude_entries"],
213
+ "source_counts": analysis["source_counts"],
214
214
  "tasks_seen": len(analysis["tasks"]),
215
215
  "decisions_made": len(analysis["decisions"]),
216
216
  "errors_resolved": len(analysis["errors"]),
@@ -243,8 +243,11 @@ def main():
243
243
  except Exception as e:
244
244
  print(f"Adaptive decay skipped: {e}")
245
245
 
246
+ source_summary = ", ".join(
247
+ f"{source}={count}" for source, count in sorted(analysis["source_counts"].items())
248
+ ) or "none"
246
249
  print(f"Reflection complete: {analysis['entry_count']} entries processed, "
247
- f"{analysis['claude_entries']} from Claude, "
250
+ f"sources: {source_summary}, "
248
251
  f"{len(analysis['errors'])} errors, "
249
252
  f"{len(analysis['self_critiques'])} critiques.")
250
253
 
package/src/server.py CHANGED
@@ -200,7 +200,7 @@ mcp = FastMCP(
200
200
  # ── Session management (3 tools) ──────────────────────────────────
201
201
 
202
202
  @mcp.tool
203
- def nexo_startup(task: str = "Startup", claude_session_id: str = "") -> str:
203
+ def nexo_startup(task: str = "Startup", claude_session_id: str = "", session_token: str = "", session_client: str = "") -> str:
204
204
  """Register new session, clean stale ones, return active sessions + alerts.
205
205
 
206
206
  Call this ONCE at the start of every conversation.
@@ -208,11 +208,18 @@ def nexo_startup(task: str = "Startup", claude_session_id: str = "") -> str:
208
208
 
209
209
  Args:
210
210
  task: Initial task description.
211
- claude_session_id: External client session token. Claude Code passes its UUID via hooks;
212
- other clients may pass a synthetic durable token when useful.
213
- Pass this to enable automatic inter-terminal inbox detection when available.
214
- """
215
- return handle_startup(task, claude_session_id=claude_session_id)
211
+ claude_session_id: Legacy alias for the external client session token.
212
+ session_token: External client session token. Claude Code passes its UUID via hooks;
213
+ other clients may pass a synthetic durable token when useful.
214
+ Pass this to enable automatic inter-terminal inbox detection when available.
215
+ session_client: Optional client label such as `claude_code` or `codex`.
216
+ """
217
+ return handle_startup(
218
+ task,
219
+ claude_session_id=claude_session_id,
220
+ session_token=session_token,
221
+ session_client=session_client,
222
+ )
216
223
 
217
224
 
218
225
  @mcp.tool
@@ -64,18 +64,35 @@ def _format_age(epoch: float) -> str:
64
64
  return f"{int(seconds / 3600)}h{int((seconds % 3600) / 60)}m"
65
65
 
66
66
 
67
- def handle_startup(task: str = "Startup", claude_session_id: str = "") -> str:
67
+ def handle_startup(
68
+ task: str = "Startup",
69
+ claude_session_id: str = "",
70
+ session_token: str = "",
71
+ session_client: str = "",
72
+ ) -> str:
68
73
  """Full startup sequence: register, clean, report.
69
74
 
70
75
  Args:
71
76
  task: Initial task description
72
- claude_session_id: External client session token. Claude Code passes its UUID via hooks;
73
- other clients may pass a synthetic durable ID when useful.
74
- Enables automatic inbox detection when hook-backed clients provide one.
77
+ claude_session_id: Legacy alias for the external client session token.
78
+ session_token: External client session token. Claude Code passes its UUID via hooks;
79
+ other clients may pass a synthetic durable ID when useful.
80
+ Enables automatic inbox detection when hook-backed clients provide one.
81
+ session_client: Optional client label such as `claude_code` or `codex`.
75
82
  """
76
83
  sid = _generate_sid()
77
84
  cleaned = clean_stale_sessions()
78
- register_session(sid, task, claude_session_id=claude_session_id)
85
+ linked_session_id = (session_token or claude_session_id or "").strip()
86
+ inferred_client = (session_client or "").strip()
87
+ if not inferred_client and claude_session_id and not session_token:
88
+ inferred_client = "claude_code"
89
+ register_session(
90
+ sid,
91
+ task,
92
+ claude_session_id=linked_session_id,
93
+ external_session_id=linked_session_id,
94
+ session_client=inferred_client,
95
+ )
79
96
  _start_keepalive(sid)
80
97
  active = get_active_sessions()
81
98
  other_sessions = [s for s in active if s["sid"] != sid]
@@ -1,4 +1,4 @@
1
- <!-- nexo-codex-agents-version: 1.0.0 -->
1
+ <!-- nexo-codex-agents-version: 1.1.0 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — NEXO Shared Brain for Codex
@@ -19,7 +19,7 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
19
19
 
20
20
  ## Codex Runtime Notes
21
21
  - Codex does not provide Claude Code hooks. Compensate explicitly: startup, heartbeat on every user message, diary on clear end-of-session intent, and memory capture on every correction or durable decision.
22
- - If a stable session token is useful, you may pass a synthetic `claude_session_id` like `codex-<task>-<date>` to `nexo_startup`; otherwise leave it blank.
22
+ - If a stable session token is useful, pass `session_token='codex-<task>-<date>'` and `session_client='codex'` to `nexo_startup`; otherwise leave them blank.
23
23
  - `nexo chat` must inject this bootstrap explicitly when it launches Codex so the session starts as NEXO even if plain global Codex startup ignores global instructions.
24
24
  - `nexo chat` should open the configured client and keep runtime/model alignment with NEXO preferences.
25
25