nexo-brain 5.0.2 → 5.0.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -11,12 +11,12 @@
11
11
  **NEXO Brain transforms any MCP-compatible AI agent from a stateless assistant into a cognitive partner that remembers, learns, forgets, adapts, and builds a relationship with you over time.**
12
12
 
13
13
  <p align="center">
14
- <a href="https://www.youtube.com/watch?v=IBs7zh7ZMG0">
14
+ <a href="https://nexo-brain.com/watch/">
15
15
  <img src="assets/nexo-brain-infographic-v5.png" alt="NEXO Brain Architecture" width="700">
16
16
  </a>
17
17
  </p>
18
18
 
19
- [Watch the overview on YouTube](https://www.youtube.com/watch?v=IBs7zh7ZMG0) · [Watch the full deep-dive](https://www.youtube.com/watch?v=bKAfowyyy5M)
19
+ [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
21
  Start here:
22
22
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
@@ -87,6 +87,19 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
87
87
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
88
88
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
89
89
 
90
+ Version `5.0.4` tightens the local runtime bridge and trims false-positive doctor noise:
91
+
92
+ - vendorable `nexo_helper.py` now resolves `NEXO_HOME` and the `nexo` CLI path robustly, so personal scripts and subprocess flows stop depending on a lucky PATH
93
+ - doctor no longer degrades because of advisory-only self-audit warnings or a single missing usage-telemetry row
94
+ - managed Claude Code and Codex bootstraps now force an immediate first answer after simple email/diary/reminder/followup reads instead of feeling hung while chaining extra lookups
95
+
96
+ Version `5.0.3` closes the next post-5.0 runtime gap:
97
+
98
+ - `nexo chat` now boots Claude Code and Codex with an explicit NEXO startup prompt instead of opening cold or leaking the target path as a fake prompt
99
+ - terminal launches now use the requested working directory as real `cwd`, so the selected project path stops behaving like chat text
100
+ - the vendorable `nexo_helper.py` bridge now bounds helper calls with a timeout instead of letting personal-script subprocess flows wait forever
101
+ - the doctor hardening from `5.0.2` remains validated on a real upgraded runtime after sync
102
+
90
103
  Version `5.0.2` closes the small post-5.0.1 doctor drift:
91
104
 
92
105
  - deep doctor now reads the live `learnings` schema correctly whether the install uses `status` or the older `archived` flag
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -22,6 +22,7 @@ from client_preferences import (
22
22
  CLIENT_CODEX,
23
23
  TERMINAL_CLIENT_KEYS,
24
24
  load_client_preferences,
25
+ normalize_client_key,
25
26
  resolve_automation_backend,
26
27
  resolve_automation_task_profile,
27
28
  resolve_client_runtime_profile,
@@ -37,6 +38,11 @@ MODEL_PRICING_USD_PER_1M = {
37
38
  "gpt-5.4": {"input": 1.25, "cached_input": 0.125, "output": 10.0},
38
39
  "gpt-5.4-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.0},
39
40
  }
41
+ INTERACTIVE_STARTUP_PROMPT = (
42
+ "Start as NEXO for this session now. Use the managed bootstrap already installed "
43
+ "for this client, run nexo_startup and nexo_heartbeat for this first turn, then "
44
+ "reply with one concise startup status in the user's language."
45
+ )
40
46
 
41
47
 
42
48
  class AgentRunnerError(RuntimeError):
@@ -312,6 +318,26 @@ def _codex_interactive_launch_flags() -> list[str]:
312
318
  return ["--sandbox", "danger-full-access", "--ask-for-approval", "never"]
313
319
 
314
320
 
321
+ def _interactive_startup_prompt(client: str) -> str:
322
+ client_key = normalize_client_key(client)
323
+ if client_key in {CLIENT_CLAUDE_CODE, CLIENT_CODEX}:
324
+ return INTERACTIVE_STARTUP_PROMPT
325
+ return ""
326
+
327
+
328
+ def _interactive_target_cwd(target: str | os.PathLike[str]) -> str:
329
+ candidate = Path(target).expanduser()
330
+ if candidate.exists() and candidate.is_file():
331
+ candidate = candidate.parent
332
+ try:
333
+ resolved = candidate.resolve()
334
+ except Exception:
335
+ resolved = candidate
336
+ if resolved.exists():
337
+ return str(resolved)
338
+ return str(Path.cwd())
339
+
340
+
315
341
  def build_interactive_client_command(
316
342
  *,
317
343
  target: str | os.PathLike[str],
@@ -322,6 +348,7 @@ def build_interactive_client_command(
322
348
  selected = resolve_terminal_client(client, preferences=prefs)
323
349
  target_path = str(Path(target).expanduser())
324
350
  profile = resolve_client_runtime_profile(selected, preferences=prefs)
351
+ startup_prompt = _interactive_startup_prompt(selected)
325
352
 
326
353
  if selected == CLIENT_CLAUDE_CODE:
327
354
  claude_bin = _resolve_claude_cli()
@@ -334,7 +361,9 @@ def build_interactive_client_command(
334
361
  cmd.extend(["--model", profile["model"]])
335
362
  if profile["reasoning_effort"]:
336
363
  cmd.extend(["--effort", profile["reasoning_effort"]])
337
- cmd.extend(["--dangerously-skip-permissions", target_path])
364
+ cmd.append("--dangerously-skip-permissions")
365
+ if startup_prompt:
366
+ cmd.append(startup_prompt)
338
367
  return selected, cmd
339
368
 
340
369
  if selected == CLIENT_CODEX:
@@ -352,6 +381,8 @@ def build_interactive_client_command(
352
381
  if profile["reasoning_effort"]:
353
382
  cmd.extend(["-c", f'model_reasoning_effort="{profile["reasoning_effort"]}"'])
354
383
  cmd.extend(["-C", target_path])
384
+ if startup_prompt:
385
+ cmd.append(startup_prompt)
355
386
  return selected, cmd
356
387
 
357
388
  raise TerminalClientUnavailableError(f"Unsupported terminal client: {selected}")
@@ -368,7 +399,7 @@ def launch_interactive_client(
368
399
  launch_env = os.environ.copy()
369
400
  if env:
370
401
  launch_env.update(env)
371
- return subprocess.run(cmd, env=launch_env)
402
+ return subprocess.run(cmd, env=launch_env, cwd=_interactive_target_cwd(target))
372
403
 
373
404
 
374
405
  def build_followup_terminal_shell_command(
package/src/cli.py CHANGED
@@ -1081,6 +1081,8 @@ def _doctor(args):
1081
1081
  return 1
1082
1082
 
1083
1083
  init_db()
1084
+ tier_label = getattr(args, "tier", "boot") or "boot"
1085
+ print(f"[NEXO] Inspecting {tier_label} diagnostics... please wait.", file=sys.stderr, flush=True)
1084
1086
  report = run_doctor(tier=args.tier, fix=args.fix)
1085
1087
  output = format_report(report, fmt="json" if args.json else "text")
1086
1088
  print(output)
@@ -65,9 +65,6 @@ def check_self_audit_summary() -> DoctorCheck:
65
65
  if error_count > 0:
66
66
  status = "critical"
67
67
  severity = "error"
68
- elif warn_count > 0:
69
- status = "degraded"
70
- severity = "warn"
71
68
  else:
72
69
  status = "healthy"
73
70
  severity = "info"
@@ -41,6 +41,7 @@ PROTECTED_MACOS_ROOTS = (
41
41
  IMMUNE_FRESHNESS = 3600 # 1 hour (runs every 30 min)
42
42
  WATCHDOG_FRESHNESS = 3600 # 1 hour (runs every 30 min)
43
43
  DEFAULT_CRON_THRESHOLD = 7200 # Fallback when manifest data is unavailable
44
+ LIVE_PROTOCOL_SESSION_FRESHNESS = 1800 # 30 minutes
44
45
  AUXILIARY_CORE_LAUNCHAGENT_IDS = {"dashboard", "prevent-sleep", "tcc-approve"}
45
46
  SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
46
47
  SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
@@ -691,6 +692,37 @@ def _load_json(path: Path) -> dict:
691
692
  return json.loads(path.read_text())
692
693
 
693
694
 
695
+ def _latest_cron_run_age_seconds(cron_id: str) -> float | None:
696
+ db_path = NEXO_HOME / "data" / "nexo.db"
697
+ if not db_path.is_file():
698
+ return None
699
+ try:
700
+ conn = sqlite3.connect(str(db_path), timeout=2)
701
+ try:
702
+ conn.row_factory = sqlite3.Row
703
+ table = conn.execute(
704
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='cron_runs'"
705
+ ).fetchone()
706
+ if not table:
707
+ return None
708
+ row = conn.execute(
709
+ "SELECT MAX(started_at) AS last_run FROM cron_runs WHERE cron_id = ?",
710
+ (cron_id,),
711
+ ).fetchone()
712
+ finally:
713
+ conn.close()
714
+ except Exception:
715
+ return None
716
+
717
+ last_run = row["last_run"] if row else None
718
+ parsed = _parse_timestamp(last_run) if last_run else None
719
+ if parsed is None:
720
+ return None
721
+ if parsed.tzinfo is None:
722
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
723
+ return max(0.0, time.time() - parsed.timestamp())
724
+
725
+
694
726
  def _latest_periodic_summary(kind: str) -> dict | None:
695
727
  pattern = f"*-{kind}-summary.json"
696
728
  candidates: list[tuple[str, Path]] = []
@@ -1097,22 +1129,9 @@ def check_immune_status() -> DoctorCheck:
1097
1129
  )
1098
1130
 
1099
1131
  age_min = age / 60
1100
- if age > IMMUNE_FRESHNESS:
1101
- return DoctorCheck(
1102
- id="runtime.immune_freshness",
1103
- tier="runtime",
1104
- status="degraded",
1105
- severity="warn",
1106
- summary=f"Immune status stale ({age_min:.0f} min old, threshold {IMMUNE_FRESHNESS // 60} min)",
1107
- evidence=[f"{status_file} last modified {age_min:.0f} minutes ago"],
1108
- repair_plan=[
1109
- "Check LaunchAgent/systemd timer for immune cron",
1110
- "nexo scripts call nexo_schedule_status --input '{}'",
1111
- ],
1112
- escalation_prompt="Investigate why immune system stopped refreshing.",
1113
- )
1114
1132
 
1115
- # Read status for additional context
1133
+ # Read status for additional context even when the file is stale so the
1134
+ # doctor can distinguish a dead immune cron from an intentional skip window.
1116
1135
  try:
1117
1136
  data = _load_json(status_file)
1118
1137
  counts = data.get("counts") or {}
@@ -1132,6 +1151,39 @@ def check_immune_status() -> DoctorCheck:
1132
1151
  status = "healthy"
1133
1152
  severity = "info"
1134
1153
  overall = "ok"
1154
+ evidence: list[str] = []
1155
+ if age > IMMUNE_FRESHNESS:
1156
+ cron_age = _latest_cron_run_age_seconds("immune")
1157
+ if cron_age is not None and cron_age <= IMMUNE_FRESHNESS:
1158
+ evidence.append(
1159
+ f"immune cron ran {cron_age / 60:.0f} minutes ago; reusing last reported status while the cron remains alive"
1160
+ )
1161
+ summary = (
1162
+ f"Immune cron is alive; last status reused ({age_min:.0f} min old file)"
1163
+ if status == "healthy"
1164
+ else f"Immune cron is alive; last {overall} status reused ({age_min:.0f} min old file)"
1165
+ )
1166
+ return DoctorCheck(
1167
+ id="runtime.immune_freshness",
1168
+ tier="runtime",
1169
+ status=status,
1170
+ severity=severity,
1171
+ summary=summary,
1172
+ evidence=evidence,
1173
+ )
1174
+ return DoctorCheck(
1175
+ id="runtime.immune_freshness",
1176
+ tier="runtime",
1177
+ status="degraded",
1178
+ severity="warn",
1179
+ summary=f"Immune status stale ({age_min:.0f} min old, threshold {IMMUNE_FRESHNESS // 60} min)",
1180
+ evidence=[f"{status_file} last modified {age_min:.0f} minutes ago"],
1181
+ repair_plan=[
1182
+ "Check LaunchAgent/systemd timer for immune cron",
1183
+ "nexo scripts call nexo_schedule_status --input '{}'",
1184
+ ],
1185
+ escalation_prompt="Investigate why immune system stopped refreshing.",
1186
+ )
1135
1187
  return DoctorCheck(
1136
1188
  id="runtime.immune_freshness",
1137
1189
  tier="runtime",
@@ -2310,7 +2362,7 @@ def check_protocol_compliance() -> DoctorCheck:
2310
2362
  tables = {
2311
2363
  row["name"]
2312
2364
  for row in conn.execute(
2313
- "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('protocol_tasks', 'protocol_debt')"
2365
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('protocol_tasks', 'protocol_debt', 'sessions')"
2314
2366
  ).fetchall()
2315
2367
  }
2316
2368
  tasks = None
@@ -2323,14 +2375,78 @@ def check_protocol_compliance() -> DoctorCheck:
2323
2375
  ORDER BY opened_at DESC""",
2324
2376
  (window,),
2325
2377
  ).fetchall()
2326
- debt_rows = conn.execute(
2327
- """SELECT severity, debt_type, COUNT(*) AS total
2328
- FROM protocol_debt
2329
- WHERE status = 'open' AND created_at >= datetime('now', ?)
2330
- GROUP BY severity, debt_type
2331
- ORDER BY total DESC, debt_type ASC""",
2332
- (window,),
2333
- ).fetchall()
2378
+ protocol_debt_cols = {
2379
+ row["name"] for row in conn.execute("PRAGMA table_info(protocol_debt)").fetchall()
2380
+ }
2381
+ protocol_task_cols = {
2382
+ row["name"] for row in conn.execute("PRAGMA table_info(protocol_tasks)").fetchall()
2383
+ }
2384
+ session_cols = set()
2385
+ if "sessions" in tables:
2386
+ session_cols = {
2387
+ row["name"] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
2388
+ }
2389
+ active_session_ids: set[str] = set()
2390
+ if {"sid", "last_update_epoch"}.issubset(session_cols):
2391
+ active_cutoff = time.time() - LIVE_PROTOCOL_SESSION_FRESHNESS
2392
+ active_session_ids = {
2393
+ str(row["sid"] or "")
2394
+ for row in conn.execute(
2395
+ "SELECT sid FROM sessions WHERE last_update_epoch >= ?",
2396
+ (active_cutoff,),
2397
+ ).fetchall()
2398
+ }
2399
+ live_guard_debt = 0
2400
+ if {"task_id", "session_id"}.issubset(protocol_debt_cols):
2401
+ task_status_expr = "'' AS task_status"
2402
+ if "status" in protocol_task_cols:
2403
+ task_status_expr = "pt.status AS task_status"
2404
+ open_debts = conn.execute(
2405
+ f"""SELECT
2406
+ pd.severity,
2407
+ pd.debt_type,
2408
+ pd.session_id,
2409
+ pd.task_id,
2410
+ pd.created_at,
2411
+ {task_status_expr}
2412
+ FROM protocol_debt pd
2413
+ LEFT JOIN protocol_tasks pt ON pt.task_id = pd.task_id
2414
+ WHERE pd.status = 'open' AND pd.created_at >= datetime('now', ?)
2415
+ ORDER BY pd.created_at DESC""",
2416
+ (window,),
2417
+ ).fetchall()
2418
+ debt_counter: dict[tuple[str, str], int] = {}
2419
+ for row in open_debts:
2420
+ severity = str(row["severity"] or "warn")
2421
+ debt_type = str(row["debt_type"] or "unknown")
2422
+ session_id = str(row["session_id"] or "")
2423
+ task_status = str(row["task_status"] or "")
2424
+ if (
2425
+ debt_type == "unacknowledged_guard_blocking"
2426
+ and task_status == "open"
2427
+ and session_id in active_session_ids
2428
+ ):
2429
+ live_guard_debt += 1
2430
+ continue
2431
+ debt_counter[(severity, debt_type)] = debt_counter.get((severity, debt_type), 0) + 1
2432
+ debt_rows = [
2433
+ {"severity": severity, "debt_type": debt_type, "total": total}
2434
+ for (severity, debt_type), total in sorted(
2435
+ debt_counter.items(),
2436
+ key=lambda item: (-item[1], item[0][1], item[0][0]),
2437
+ )
2438
+ ]
2439
+ else:
2440
+ debt_rows = [
2441
+ dict(row) for row in conn.execute(
2442
+ """SELECT severity, debt_type, COUNT(*) AS total
2443
+ FROM protocol_debt
2444
+ WHERE status = 'open' AND created_at >= datetime('now', ?)
2445
+ GROUP BY severity, debt_type
2446
+ ORDER BY total DESC, debt_type ASC""",
2447
+ (window,),
2448
+ ).fetchall()
2449
+ ]
2334
2450
  has_cortex_evaluations = bool(
2335
2451
  conn.execute(
2336
2452
  "SELECT 1 FROM sqlite_master WHERE type='table' AND name='cortex_evaluations'"
@@ -2375,7 +2491,7 @@ def check_protocol_compliance() -> DoctorCheck:
2375
2491
  if str(row["opened_at"] or "") >= first_cortex_eval_at
2376
2492
  ]
2377
2493
  decision_ok = [row for row in decision_eligible if row["task_id"] in covered_tasks]
2378
- decision_metric_ready = len(decision_eligible) >= 3
2494
+ decision_metric_ready = bool(first_cortex_eval_at) and len(decision_eligible) >= 3
2379
2495
 
2380
2496
  score_parts = []
2381
2497
  if verify_required:
@@ -2417,6 +2533,8 @@ def check_protocol_compliance() -> DoctorCheck:
2417
2533
  evidence.append("high-stakes decision-eval rollout not yet seeded in the live window")
2418
2534
  for row in debt_rows[:5]:
2419
2535
  evidence.append(f"open {row['severity']} debt — {row['debt_type']}: {row['total']}")
2536
+ if live_guard_debt:
2537
+ evidence.append(f"live in-progress guard acknowledgements pending: {live_guard_debt}")
2420
2538
 
2421
2539
  repair_plan: list[str] = []
2422
2540
  if verify_required and len(verify_ok) != len(verify_required):
@@ -2725,20 +2843,51 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2725
2843
  repair_plan=["Run NEXO migrations before trusting automation cost/parity metrics"],
2726
2844
  escalation_prompt="Shared automation runs are happening without the telemetry table that release metrics depend on.",
2727
2845
  )
2728
-
2729
- row = conn.execute(
2730
- """
2731
- SELECT
2732
- COUNT(*) AS runs,
2733
- SUM(CASE WHEN (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
2734
- SUM(CASE WHEN total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
2735
- SUM(CASE WHEN cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
2736
- GROUP_CONCAT(DISTINCT backend) AS backends
2737
- FROM automation_runs
2738
- WHERE created_at >= datetime('now', ?)
2739
- """,
2740
- (f"-{days} days",),
2741
- ).fetchone()
2846
+ columns = {
2847
+ row["name"] for row in conn.execute("PRAGMA table_info(automation_runs)").fetchall()
2848
+ }
2849
+ schema_has_status = {
2850
+ "status",
2851
+ "input_tokens",
2852
+ "cached_input_tokens",
2853
+ "output_tokens",
2854
+ "total_cost_usd",
2855
+ "cost_source",
2856
+ "backend",
2857
+ "created_at",
2858
+ }.issubset(columns)
2859
+ if schema_has_status:
2860
+ row = conn.execute(
2861
+ """
2862
+ SELECT
2863
+ COUNT(*) AS runs,
2864
+ SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS successful_runs,
2865
+ SUM(CASE WHEN status != 'ok' THEN 1 ELSE 0 END) AS failed_runs,
2866
+ SUM(CASE WHEN status = 'ok' AND (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
2867
+ SUM(CASE WHEN status = 'ok' AND total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
2868
+ SUM(CASE WHEN status = 'ok' AND cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
2869
+ GROUP_CONCAT(DISTINCT backend) AS backends
2870
+ FROM automation_runs
2871
+ WHERE created_at >= datetime('now', ?)
2872
+ """,
2873
+ (f"-{days} days",),
2874
+ ).fetchone()
2875
+ else:
2876
+ row = conn.execute(
2877
+ """
2878
+ SELECT
2879
+ COUNT(*) AS runs,
2880
+ COUNT(*) AS successful_runs,
2881
+ 0 AS failed_runs,
2882
+ SUM(CASE WHEN (input_tokens + cached_input_tokens + output_tokens) > 0 THEN 1 ELSE 0 END) AS usage_runs,
2883
+ SUM(CASE WHEN total_cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS cost_runs,
2884
+ SUM(CASE WHEN cost_source = 'pricing_unavailable' THEN 1 ELSE 0 END) AS pricing_gaps,
2885
+ GROUP_CONCAT(DISTINCT backend) AS backends
2886
+ FROM automation_runs
2887
+ WHERE created_at >= datetime('now', ?)
2888
+ """,
2889
+ (f"-{days} days",),
2890
+ ).fetchone()
2742
2891
  finally:
2743
2892
  conn.close()
2744
2893
  except Exception as exc:
@@ -2766,18 +2915,27 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2766
2915
  escalation_prompt="",
2767
2916
  )
2768
2917
 
2918
+ successful_runs = int((row["successful_runs"] if row else 0) or 0)
2919
+ failed_runs = int((row["failed_runs"] if row else 0) or 0)
2769
2920
  usage_runs = int((row["usage_runs"] if row else 0) or 0)
2770
2921
  cost_runs = int((row["cost_runs"] if row else 0) or 0)
2771
2922
  pricing_gaps = int((row["pricing_gaps"] if row else 0) or 0)
2772
- usage_coverage = round((usage_runs / total_runs) * 100, 1)
2773
- cost_coverage = round((cost_runs / total_runs) * 100, 1)
2923
+ usage_denominator = successful_runs or total_runs
2924
+ cost_denominator = successful_runs or total_runs
2925
+ missing_usage_runs = max(0, usage_denominator - usage_runs) if usage_denominator else 0
2926
+ usage_coverage = round((usage_runs / usage_denominator) * 100, 1) if usage_denominator else 100.0
2927
+ cost_coverage = round((cost_runs / cost_denominator) * 100, 1) if cost_denominator else 100.0
2774
2928
  evidence = [
2775
2929
  f"window={days}d",
2776
2930
  f"runs={total_runs}",
2931
+ f"successful_runs={successful_runs}",
2932
+ f"failed_runs={failed_runs}",
2777
2933
  f"usage_coverage={usage_coverage}%",
2778
2934
  f"cost_coverage={cost_coverage}%",
2779
2935
  f"pricing_gaps={pricing_gaps}",
2780
2936
  ]
2937
+ if missing_usage_runs:
2938
+ evidence.append(f"missing_usage_runs={missing_usage_runs}")
2781
2939
  backends = str((row["backends"] if row else "") or "").strip()
2782
2940
  if backends:
2783
2941
  evidence.append(f"backends={backends}")
@@ -2785,7 +2943,7 @@ def check_automation_telemetry(days: int = 7) -> DoctorCheck:
2785
2943
  status = "healthy"
2786
2944
  severity = "info"
2787
2945
  repair_plan: list[str] = []
2788
- if usage_coverage < 100.0:
2946
+ if usage_coverage < 100.0 and missing_usage_runs > 1:
2789
2947
  status = "degraded"
2790
2948
  severity = "warn"
2791
2949
  repair_plan.append("Restore backend usage parsing so automation runs always emit token telemetry")
@@ -330,16 +330,17 @@ def _regex_fallback_classify(text: str) -> str | None:
330
330
  return None
331
331
 
332
332
 
333
- def _classify_signal(text: str) -> str | None:
333
+ def _classify_signal(text: str, *, allow_llm: bool = True) -> str | None:
334
334
  """Classify text into a signal type, or None if nothing interesting."""
335
- llm_result = _llm_classify_signal(text)
336
- if llm_result.get("available"):
337
- confidence = float(llm_result.get("confidence", 0.0) or 0.0)
338
- label = llm_result.get("label")
339
- if label is None and confidence >= _LLM_CONFIDENCE_THRESHOLD:
340
- return None
341
- if isinstance(label, str) and confidence >= _LLM_CONFIDENCE_THRESHOLD:
342
- return label
335
+ if allow_llm:
336
+ llm_result = _llm_classify_signal(text)
337
+ if llm_result.get("available"):
338
+ confidence = float(llm_result.get("confidence", 0.0) or 0.0)
339
+ label = llm_result.get("label")
340
+ if label is None and confidence >= _LLM_CONFIDENCE_THRESHOLD:
341
+ return None
342
+ if isinstance(label, str) and confidence >= _LLM_CONFIDENCE_THRESHOLD:
343
+ return label
343
344
 
344
345
  scores = _semantic_signal_scores(text)
345
346
  if scores:
@@ -378,6 +379,8 @@ def detect_drive_signal(
378
379
  source: str,
379
380
  source_id: str = "",
380
381
  area: str = "",
382
+ *,
383
+ allow_llm: bool = False,
381
384
  ) -> dict | None:
382
385
  """Analyze text for interesting signals. Creates or reinforces.
383
386
 
@@ -387,7 +390,7 @@ def detect_drive_signal(
387
390
  if not context_hint or len(context_hint.strip()) < 15:
388
391
  return None
389
392
 
390
- signal_type = _classify_signal(context_hint)
393
+ signal_type = _classify_signal(context_hint, allow_llm=allow_llm)
391
394
  if not signal_type:
392
395
  return None
393
396
 
@@ -31,6 +31,14 @@ SESSION_PORTABILITY_DIR = NEXO_HOME / "operations" / "session-portability"
31
31
  _keepalive_threads: dict[str, threading.Event] = {} # sid → stop_event
32
32
 
33
33
 
34
+ def _env_flag(name: str, default: bool = False) -> bool:
35
+ """Parse a boolean environment flag with sane falsey values."""
36
+ raw = os.environ.get(name)
37
+ if raw is None:
38
+ return default
39
+ return raw.strip().lower() not in {"", "0", "false", "no", "off"}
40
+
41
+
34
42
  def _keepalive_loop(sid: str, stop_event: threading.Event) -> None:
35
43
  """Periodically touch the session's last_update_epoch until stopped."""
36
44
  while not stop_event.wait(KEEPALIVE_INTERVAL):
@@ -522,7 +530,13 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
522
530
  try:
523
531
  if context_hint and len(context_hint.strip()) >= 15:
524
532
  from tools_drive import detect_drive_signal as _detect_drive
525
- _drive_result = _detect_drive(context_hint, source="heartbeat", source_id=sid)
533
+ _drive_allow_llm = _env_flag("NEXO_DRIVE_LLM_IN_HEARTBEAT", default=False)
534
+ _drive_result = _detect_drive(
535
+ context_hint,
536
+ source="heartbeat",
537
+ source_id=sid,
538
+ allow_llm=_drive_allow_llm,
539
+ )
526
540
  if _drive_result:
527
541
  # Check for READY signals relevant to current area
528
542
  from db import get_drive_signals as _get_drive
@@ -1,4 +1,4 @@
1
- <!-- nexo-claude-md-version: 2.1.2 -->
1
+ <!-- nexo-claude-md-version: 2.1.3 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — Cognitive Co-Operator
@@ -28,6 +28,13 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
28
28
  5. If a correction revealed a reusable pattern, capture or supersede the learning immediately so contradictory rules do not remain active.
29
29
  6. On clear end-of-session intent, write the diary before replying.
30
30
 
31
+ ## Response Pacing
32
+ - After the first relevant tool or artifact result, reply with the answer immediately instead of silently chaining more investigation.
33
+ - Only continue into deeper investigation before the first visible answer if the user explicitly asked for a deep investigation or the situation is urgent/high-risk.
34
+ - For single-artifact asks (email, message, diary item, reminder, prior fact), retrieve the artifact, summarize it, then decide whether further action is needed.
35
+ - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
36
+ - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first user-visible answer unless the user explicitly asked for it.
37
+
31
38
  <!-- nexo:start:profile -->
32
39
  ## User Profile
33
40
  - **Calibration:** `{{NEXO_HOME}}/brain/calibration.json` (personality settings + language + user name)
@@ -1,4 +1,4 @@
1
- <!-- nexo-codex-agents-version: 1.2.2 -->
1
+ <!-- nexo-codex-agents-version: 1.2.3 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — NEXO Shared Brain for Codex
@@ -25,6 +25,13 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
25
25
  5. If a correction revealed a reusable pattern, capture or supersede the learning immediately so contradictory rules do not remain active.
26
26
  6. On clear end-of-session intent, write the diary before replying.
27
27
 
28
+ ## Response Pacing
29
+ - After the first relevant tool or artifact result, answer immediately instead of silently chaining more investigation.
30
+ - Only keep investigating before the first visible answer if the user explicitly requested deep investigation or the situation is urgent/high-risk.
31
+ - For single-artifact asks (email, message, diary item, reminder, prior fact), retrieve it, summarize it, then decide if more work is needed.
32
+ - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
33
+ - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first visible answer unless the user explicitly asked for it.
34
+
28
35
  ## Codex Runtime Notes
29
36
  - Codex does not provide Claude Code hooks, so protocol discipline must be explicit.
30
37
  - If a stable session token is useful, pass `session_token='codex-<task>-<date>'` and `session_client='codex'` to `nexo_startup`; otherwise leave them blank.
@@ -10,13 +10,36 @@ from __future__ import annotations
10
10
 
11
11
  import json
12
12
  import os
13
+ import shutil
13
14
  import subprocess
14
15
  import sys
15
16
  from pathlib import Path
16
17
 
17
18
 
18
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ def _detect_nexo_home() -> Path:
20
+ env_home = os.environ.get("NEXO_HOME", "").strip()
21
+ if env_home:
22
+ return Path(env_home).expanduser()
23
+
24
+ helper_path = Path(__file__).resolve()
25
+ inferred_home = helper_path.parent.parent
26
+ if (
27
+ helper_path.parent.name == "templates"
28
+ and (inferred_home / "scripts").is_dir()
29
+ and (inferred_home / "config").is_dir()
30
+ ):
31
+ return inferred_home
32
+
33
+ claude_home = Path.home() / "claude"
34
+ if claude_home.is_dir():
35
+ return claude_home
36
+
37
+ return Path.home() / ".nexo"
38
+
39
+
40
+ NEXO_HOME = _detect_nexo_home()
19
41
  DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
42
+ DEFAULT_NEXO_TIMEOUT_SECONDS = max(15, int(os.environ.get("NEXO_HELPER_TIMEOUT", "90")))
20
43
 
21
44
 
22
45
  def _load_schedule() -> dict:
@@ -48,15 +71,44 @@ def _load_bootstrap_prompt() -> str:
48
71
  return ""
49
72
 
50
73
 
74
+ def _resolve_nexo_cli() -> str:
75
+ candidates = []
76
+
77
+ env_cli = os.environ.get("NEXO_BIN", "").strip()
78
+ if env_cli:
79
+ candidates.append(Path(env_cli).expanduser())
80
+
81
+ candidates.extend(
82
+ [
83
+ NEXO_HOME / "bin" / "nexo",
84
+ Path.home() / ".local" / "bin" / "nexo",
85
+ Path.home() / "bin" / "nexo",
86
+ ]
87
+ )
88
+
89
+ for candidate in candidates:
90
+ try:
91
+ if candidate.is_file() and os.access(candidate, os.X_OK):
92
+ return str(candidate)
93
+ except OSError:
94
+ continue
95
+
96
+ return shutil.which("nexo") or "nexo"
97
+
98
+
51
99
  def run_nexo(args: list[str]) -> str:
52
100
  """Run a nexo CLI command and return stdout.
53
101
 
54
102
  Raises RuntimeError on non-zero exit.
55
103
  """
104
+ env = os.environ.copy()
105
+ env.setdefault("NEXO_HOME", str(NEXO_HOME))
56
106
  result = subprocess.run(
57
- ["nexo", *args],
107
+ [_resolve_nexo_cli(), *args],
58
108
  capture_output=True,
59
109
  text=True,
110
+ timeout=DEFAULT_NEXO_TIMEOUT_SECONDS,
111
+ env=env,
60
112
  )
61
113
  if result.returncode != 0:
62
114
  raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"nexo exited {result.returncode}")
@@ -81,12 +133,72 @@ def call_tool_json(name: str, payload: dict | None = None) -> dict:
81
133
  return json.loads(out)
82
134
 
83
135
 
136
+ def _extract_json_object(text: str) -> dict:
137
+ raw = str(text or "").strip()
138
+ if not raw:
139
+ raise RuntimeError("Automation backend returned empty output.")
140
+
141
+ if raw.startswith("```"):
142
+ lines = raw.splitlines()
143
+ if len(lines) >= 2:
144
+ end = len(lines)
145
+ for i in range(len(lines) - 1, 0, -1):
146
+ if lines[i].strip() == "```":
147
+ end = i
148
+ break
149
+ raw = "\n".join(lines[1:end]).strip()
150
+
151
+ try:
152
+ parsed = json.loads(raw)
153
+ if isinstance(parsed, dict):
154
+ return parsed
155
+ except Exception:
156
+ pass
157
+
158
+ start = raw.find("{")
159
+ if start < 0:
160
+ raise RuntimeError("Automation backend did not return a JSON object.")
161
+
162
+ depth = 0
163
+ in_string = False
164
+ escape = False
165
+ for idx in range(start, len(raw)):
166
+ ch = raw[idx]
167
+ if in_string:
168
+ if escape:
169
+ escape = False
170
+ elif ch == "\\":
171
+ escape = True
172
+ elif ch == '"':
173
+ in_string = False
174
+ continue
175
+
176
+ if ch == '"':
177
+ in_string = True
178
+ elif ch == "{":
179
+ depth += 1
180
+ elif ch == "}":
181
+ depth -= 1
182
+ if depth == 0:
183
+ candidate = raw[start : idx + 1]
184
+ try:
185
+ parsed = json.loads(candidate)
186
+ except Exception as exc:
187
+ raise RuntimeError(f"Automation backend returned invalid JSON object: {exc}") from exc
188
+ if isinstance(parsed, dict):
189
+ return parsed
190
+ break
191
+
192
+ raise RuntimeError("Automation backend did not return a parseable JSON object.")
193
+
194
+
84
195
  def run_automation_text(
85
196
  prompt: str,
86
197
  *,
87
198
  model: str = "",
88
199
  reasoning_effort: str = "",
89
200
  cwd: str = "",
201
+ timeout: int | None = None,
90
202
  allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
91
203
  append_system_prompt: str = "",
92
204
  include_bootstrap: bool = True,
@@ -128,7 +240,58 @@ def run_automation_text(
128
240
  capture_output=True,
129
241
  text=True,
130
242
  env=env,
243
+ timeout=(int(timeout) if timeout else None),
131
244
  )
132
245
  if result.returncode != 0:
133
246
  raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"automation backend exited {result.returncode}")
134
247
  return result.stdout
248
+
249
+
250
+ def run_automation_json(
251
+ prompt: str,
252
+ *,
253
+ model: str = "",
254
+ reasoning_effort: str = "",
255
+ cwd: str = "",
256
+ timeout: int | None = None,
257
+ allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
258
+ append_system_prompt: str = "",
259
+ include_bootstrap: bool = True,
260
+ ) -> dict:
261
+ """Run the configured backend and return a parsed JSON object."""
262
+ runner = NEXO_HOME / "scripts" / "nexo-agent-run.py"
263
+ if not runner.exists():
264
+ raise RuntimeError(f"Automation runner not found: {runner}")
265
+
266
+ cmd = [sys.executable, str(runner), "--prompt", prompt, "--output-format", "json"]
267
+ if model:
268
+ cmd.extend(["--model", model])
269
+ if reasoning_effort:
270
+ cmd.extend(["--reasoning-effort", reasoning_effort])
271
+ if cwd:
272
+ cmd.extend(["--cwd", cwd])
273
+ merged_system_prompt = []
274
+ if include_bootstrap:
275
+ bootstrap = _load_bootstrap_prompt()
276
+ if bootstrap:
277
+ merged_system_prompt.append(bootstrap)
278
+ if append_system_prompt:
279
+ merged_system_prompt.append(append_system_prompt)
280
+ if merged_system_prompt:
281
+ cmd.extend(["--append-system-prompt", "\n\n".join(merged_system_prompt)])
282
+ if allowed_tools:
283
+ cmd.extend(["--allowed-tools", allowed_tools])
284
+
285
+ env = os.environ.copy()
286
+ env.setdefault("NEXO_HOME", str(NEXO_HOME))
287
+ env.setdefault("NEXO_CODE", env.get("NEXO_CODE", str(NEXO_HOME)))
288
+ result = subprocess.run(
289
+ cmd,
290
+ capture_output=True,
291
+ text=True,
292
+ env=env,
293
+ timeout=(int(timeout) if timeout else None),
294
+ )
295
+ if result.returncode != 0:
296
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"automation backend exited {result.returncode}")
297
+ return _extract_json_object(result.stdout)