nexo-brain 5.0.1 → 5.0.3

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.1",
3
+ "version": "5.0.3",
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,18 @@ 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.3` closes the next post-5.0 runtime gap:
91
+
92
+ - `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
93
+ - terminal launches now use the requested working directory as real `cwd`, so the selected project path stops behaving like chat text
94
+ - the vendorable `nexo_helper.py` bridge now bounds helper calls with a timeout instead of letting personal-script subprocess flows wait forever
95
+ - the doctor hardening from `5.0.2` remains validated on a real upgraded runtime after sync
96
+
97
+ Version `5.0.2` closes the small post-5.0.1 doctor drift:
98
+
99
+ - deep doctor now reads the live `learnings` schema correctly whether the install uses `status` or the older `archived` flag
100
+ - a real upgraded runtime was revalidated with `nexo update`, `nexo doctor --tier deep`, `nexo doctor --tier all`, and a fresh Claude Code startup smoke
101
+
90
102
  Version `5.0.1` hardens the live 5.0 upgrade path:
91
103
 
92
104
  - managed Claude Code hooks are now cleaned up when an older release left obsolete core-managed entries behind
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
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)
@@ -264,7 +264,20 @@ def check_learning_count() -> DoctorCheck:
264
264
  severity="info",
265
265
  summary="No learnings table yet",
266
266
  )
267
- count = conn.execute("SELECT COUNT(*) FROM learnings WHERE archived=0").fetchone()[0]
267
+ columns = {
268
+ row[1]
269
+ for row in conn.execute("PRAGMA table_info(learnings)").fetchall()
270
+ }
271
+ if "status" in columns:
272
+ count = conn.execute(
273
+ "SELECT COUNT(*) FROM learnings WHERE COALESCE(status, 'active') != 'archived'"
274
+ ).fetchone()[0]
275
+ elif "archived" in columns:
276
+ count = conn.execute(
277
+ "SELECT COUNT(*) FROM learnings WHERE archived=0"
278
+ ).fetchone()[0]
279
+ else:
280
+ count = conn.execute("SELECT COUNT(*) FROM learnings").fetchone()[0]
268
281
  finally:
269
282
  conn.close()
270
283
  return DoctorCheck(
@@ -272,15 +285,16 @@ def check_learning_count() -> DoctorCheck:
272
285
  tier="deep",
273
286
  status="healthy",
274
287
  severity="info",
275
- summary=f"{count} active learnings in memory",
288
+ summary=f"{count} non-archived learnings in memory",
276
289
  )
277
290
  except Exception as e:
278
291
  return DoctorCheck(
279
292
  id="deep.learning_count",
280
293
  tier="deep",
281
- status="healthy",
282
- severity="info",
283
- summary=f"Learning check skipped: {e}",
294
+ status="degraded",
295
+ severity="warn",
296
+ summary=f"Learning check unreadable: {e}",
297
+ evidence=[str(e)],
284
298
  )
285
299
 
286
300
 
@@ -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'"
@@ -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,14 +2915,20 @@ 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
+ usage_coverage = round((usage_runs / usage_denominator) * 100, 1) if usage_denominator else 100.0
2926
+ cost_coverage = round((cost_runs / cost_denominator) * 100, 1) if cost_denominator else 100.0
2774
2927
  evidence = [
2775
2928
  f"window={days}d",
2776
2929
  f"runs={total_runs}",
2930
+ f"successful_runs={successful_runs}",
2931
+ f"failed_runs={failed_runs}",
2777
2932
  f"usage_coverage={usage_coverage}%",
2778
2933
  f"cost_coverage={cost_coverage}%",
2779
2934
  f"pricing_gaps={pricing_gaps}",
@@ -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,11 @@ 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
+
31
36
  <!-- nexo:start:profile -->
32
37
  ## User Profile
33
38
  - **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,11 @@ 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
+
28
33
  ## Codex Runtime Notes
29
34
  - Codex does not provide Claude Code hooks, so protocol discipline must be explicit.
30
35
  - If a stable session token is useful, pass `session_token='codex-<task>-<date>'` and `session_client='codex'` to `nexo_startup`; otherwise leave them blank.
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
 
18
18
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
19
  DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*"
20
+ DEFAULT_NEXO_TIMEOUT_SECONDS = max(15, int(os.environ.get("NEXO_HELPER_TIMEOUT", "90")))
20
21
 
21
22
 
22
23
  def _load_schedule() -> dict:
@@ -57,6 +58,7 @@ def run_nexo(args: list[str]) -> str:
57
58
  ["nexo", *args],
58
59
  capture_output=True,
59
60
  text=True,
61
+ timeout=DEFAULT_NEXO_TIMEOUT_SECONDS,
60
62
  )
61
63
  if result.returncode != 0:
62
64
  raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"nexo exited {result.returncode}")