nexo-brain 2.6.21 → 3.0.0

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -8,6 +8,7 @@ import shlex
8
8
  import shutil
9
9
  import subprocess
10
10
  import tempfile
11
+ import time
11
12
  import tomllib
12
13
  from pathlib import Path
13
14
 
@@ -18,6 +19,7 @@ from client_preferences import (
18
19
  TERMINAL_CLIENT_KEYS,
19
20
  load_client_preferences,
20
21
  resolve_automation_backend,
22
+ resolve_automation_task_profile,
21
23
  resolve_client_runtime_profile,
22
24
  resolve_terminal_client,
23
25
  )
@@ -25,6 +27,12 @@ from client_preferences import (
25
27
 
26
28
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
27
29
  CLAUDE_LEGACY_MODEL_HINTS = {"opus", "sonnet"}
30
+ MODEL_PRICING_USD_PER_1M = {
31
+ # Pricing snapshot used only when the backend does not return explicit cost.
32
+ # Codex model names map to the current GPT-5 family pricing.
33
+ "gpt-5.4": {"input": 1.25, "cached_input": 0.125, "output": 10.0},
34
+ "gpt-5.4-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.0},
35
+ }
28
36
 
29
37
 
30
38
  class AgentRunnerError(RuntimeError):
@@ -39,6 +47,192 @@ class AutomationBackendUnavailableError(AgentRunnerError):
39
47
  """Raised when the configured automation backend is unavailable."""
40
48
 
41
49
 
50
+ def _canonical_pricing_model(model: str) -> str:
51
+ lowered = str(model or "").strip().lower()
52
+ lowered = lowered.split("[", 1)[0]
53
+ aliases = {
54
+ "gpt-5": "gpt-5.4",
55
+ "gpt-5.4": "gpt-5.4",
56
+ "gpt-5-mini": "gpt-5.4-mini",
57
+ "gpt-5.4-mini": "gpt-5.4-mini",
58
+ }
59
+ return aliases.get(lowered, lowered)
60
+
61
+
62
+ def _estimate_openai_cost_usd(model: str, *, input_tokens: int, cached_input_tokens: int, output_tokens: int) -> tuple[float | None, str]:
63
+ pricing = MODEL_PRICING_USD_PER_1M.get(_canonical_pricing_model(model))
64
+ if not pricing:
65
+ return None, "pricing_unavailable"
66
+ total = 0.0
67
+ total += (max(0, int(input_tokens or 0)) / 1_000_000.0) * pricing["input"]
68
+ total += (max(0, int(cached_input_tokens or 0)) / 1_000_000.0) * pricing["cached_input"]
69
+ total += (max(0, int(output_tokens or 0)) / 1_000_000.0) * pricing["output"]
70
+ return round(total, 6), "pricing_snapshot"
71
+
72
+
73
+ def _safe_json_loads(raw: str) -> dict | list | None:
74
+ try:
75
+ return json.loads(raw)
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def _extract_claude_telemetry(raw_stdout: str, *, requested_output_format: str) -> tuple[str, dict]:
81
+ payload = _safe_json_loads(raw_stdout) if str(raw_stdout or "").strip().startswith("{") else None
82
+ if not isinstance(payload, dict):
83
+ return raw_stdout or "", {
84
+ "telemetry_source": "missing",
85
+ "cost_source": "missing",
86
+ "usage": {},
87
+ "warnings": ["backend did not return parseable JSON telemetry"],
88
+ }
89
+
90
+ result_payload = payload.get("result", "")
91
+ if requested_output_format and requested_output_format.lower() == "json" and not isinstance(result_payload, str):
92
+ final_stdout = json.dumps(result_payload, ensure_ascii=False)
93
+ else:
94
+ final_stdout = result_payload if isinstance(result_payload, str) else json.dumps(result_payload, ensure_ascii=False)
95
+
96
+ usage = payload.get("usage") or {}
97
+ model_usage = payload.get("modelUsage") or {}
98
+ explicit_cost = payload.get("total_cost_usd")
99
+ if explicit_cost is None and isinstance(model_usage, dict):
100
+ explicit_cost = sum(
101
+ float((item or {}).get("costUSD") or 0.0)
102
+ for item in model_usage.values()
103
+ if isinstance(item, dict)
104
+ )
105
+
106
+ return final_stdout, {
107
+ "telemetry_source": "claude_json",
108
+ "cost_source": "backend",
109
+ "usage": {
110
+ "input_tokens": int(usage.get("input_tokens") or 0),
111
+ "cached_input_tokens": int(usage.get("cache_read_input_tokens") or 0),
112
+ "output_tokens": int(usage.get("output_tokens") or 0),
113
+ },
114
+ "total_cost_usd": float(explicit_cost) if explicit_cost is not None else None,
115
+ "raw": payload,
116
+ "warnings": [],
117
+ }
118
+
119
+
120
+ def _extract_codex_telemetry(stream_stdout: str, *, final_stdout: str, model: str) -> tuple[str, dict]:
121
+ usage_payload: dict = {}
122
+ raw_events: list[dict] = []
123
+ for line in str(stream_stdout or "").splitlines():
124
+ line = line.strip()
125
+ if not line.startswith("{"):
126
+ continue
127
+ payload = _safe_json_loads(line)
128
+ if not isinstance(payload, dict):
129
+ continue
130
+ raw_events.append(payload)
131
+ if payload.get("type") == "turn.completed" and isinstance(payload.get("usage"), dict):
132
+ usage_payload = payload["usage"]
133
+
134
+ usage = {
135
+ "input_tokens": int(usage_payload.get("input_tokens") or 0),
136
+ "cached_input_tokens": int(usage_payload.get("cached_input_tokens") or 0),
137
+ "output_tokens": int(usage_payload.get("output_tokens") or 0),
138
+ }
139
+ total_cost_usd = usage_payload.get("total_cost_usd")
140
+ cost_source = "backend" if total_cost_usd is not None else "missing"
141
+ warnings: list[str] = []
142
+ if total_cost_usd is None:
143
+ estimated_cost, estimated_source = _estimate_openai_cost_usd(
144
+ model,
145
+ input_tokens=usage["input_tokens"],
146
+ cached_input_tokens=usage["cached_input_tokens"],
147
+ output_tokens=usage["output_tokens"],
148
+ )
149
+ total_cost_usd = estimated_cost
150
+ cost_source = estimated_source
151
+ if estimated_cost is None:
152
+ warnings.append(f"no pricing snapshot available for model `{model}`")
153
+
154
+ if not usage_payload:
155
+ warnings.append("backend did not return usage telemetry")
156
+
157
+ return final_stdout, {
158
+ "telemetry_source": "codex_jsonl",
159
+ "cost_source": cost_source,
160
+ "usage": usage,
161
+ "total_cost_usd": float(total_cost_usd) if total_cost_usd is not None else None,
162
+ "raw": raw_events[-8:],
163
+ "warnings": warnings,
164
+ }
165
+
166
+
167
+ def _append_stderr(stderr: str, message: str) -> str:
168
+ bits = [part for part in [str(stderr or "").rstrip(), str(message or "").strip()] if part]
169
+ if not bits:
170
+ return ""
171
+ return "\n".join(bits) + "\n"
172
+
173
+
174
+ def _record_automation_run(
175
+ *,
176
+ backend: str,
177
+ task_profile: str,
178
+ model: str,
179
+ reasoning_effort: str,
180
+ cwd: Path,
181
+ output_format: str,
182
+ prompt: str,
183
+ returncode: int,
184
+ duration_ms: int,
185
+ telemetry: dict,
186
+ ) -> tuple[bool, str]:
187
+ try:
188
+ from db._core import get_db
189
+ except Exception as exc:
190
+ return False, f"automation telemetry unavailable: {exc}"
191
+
192
+ try:
193
+ conn = get_db()
194
+ usage = telemetry.get("usage") or {}
195
+ conn.execute(
196
+ """
197
+ INSERT INTO automation_runs (
198
+ backend, task_profile, model, reasoning_effort, cwd, output_format,
199
+ prompt_chars, returncode, duration_ms,
200
+ input_tokens, cached_input_tokens, output_tokens,
201
+ total_cost_usd, telemetry_source, cost_source, status, metadata
202
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
203
+ """,
204
+ (
205
+ backend,
206
+ task_profile or "default",
207
+ model,
208
+ reasoning_effort,
209
+ str(cwd),
210
+ output_format or "text",
211
+ len(prompt or ""),
212
+ int(returncode),
213
+ int(duration_ms),
214
+ int(usage.get("input_tokens") or 0),
215
+ int(usage.get("cached_input_tokens") or 0),
216
+ int(usage.get("output_tokens") or 0),
217
+ telemetry.get("total_cost_usd"),
218
+ telemetry.get("telemetry_source", ""),
219
+ telemetry.get("cost_source", ""),
220
+ "ok" if int(returncode) == 0 else "failed",
221
+ json.dumps(
222
+ {
223
+ "warnings": telemetry.get("warnings") or [],
224
+ "raw": telemetry.get("raw") or {},
225
+ },
226
+ ensure_ascii=False,
227
+ ),
228
+ ),
229
+ )
230
+ conn.commit()
231
+ return True, ""
232
+ except Exception as exc:
233
+ return False, f"automation telemetry unavailable: {exc}"
234
+
235
+
42
236
  def _resolve_claude_cli() -> str:
43
237
  saved = NEXO_HOME / "config" / "claude-cli-path"
44
238
  if saved.exists():
@@ -109,6 +303,10 @@ def _codex_initial_messages_config(prompt_text: str) -> str:
109
303
  return f'initial_messages=[{{role="system",content={json.dumps(prompt_text, ensure_ascii=False)}}}]'
110
304
 
111
305
 
306
+ def _codex_interactive_launch_flags() -> list[str]:
307
+ return ["--sandbox", "danger-full-access", "--ask-for-approval", "never"]
308
+
309
+
112
310
  def build_interactive_client_command(
113
311
  *,
114
312
  target: str | os.PathLike[str],
@@ -140,7 +338,7 @@ def build_interactive_client_command(
140
338
  raise TerminalClientUnavailableError(
141
339
  "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
142
340
  )
143
- cmd = [codex_bin]
341
+ cmd = [codex_bin, *_codex_interactive_launch_flags()]
144
342
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
145
343
  if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
146
344
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
@@ -201,7 +399,7 @@ def build_followup_terminal_shell_command(
201
399
  "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
202
400
  )
203
401
  target_cwd = str(Path(cwd).expanduser()) if cwd else str(Path.home())
204
- cmd = [codex_bin]
402
+ cmd = [codex_bin, *_codex_interactive_launch_flags()]
205
403
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
206
404
  if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
207
405
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
@@ -241,6 +439,27 @@ def _resolve_runtime_model_and_effort(
241
439
  return requested_model, requested_effort
242
440
 
243
441
 
442
+ def _backend_is_available(backend: str) -> bool:
443
+ if backend == CLIENT_CLAUDE_CODE:
444
+ return bool(_resolve_claude_cli())
445
+ if backend == CLIENT_CODEX:
446
+ return bool(_resolve_codex_cli())
447
+ return False
448
+
449
+
450
+ def _resolve_available_backend(selected_backend: str, *, preferences: dict | None = None) -> str:
451
+ if _backend_is_available(selected_backend):
452
+ return selected_backend
453
+ prefs = preferences or load_client_preferences()
454
+ preferred = resolve_automation_backend(preferences=prefs)
455
+ for candidate in (preferred, CLIENT_CLAUDE_CODE, CLIENT_CODEX):
456
+ if candidate == selected_backend or candidate == BACKEND_NONE:
457
+ continue
458
+ if _backend_is_available(candidate):
459
+ return candidate
460
+ return selected_backend
461
+
462
+
244
463
  def _build_codex_prompt(
245
464
  prompt: str,
246
465
  *,
@@ -248,7 +467,18 @@ def _build_codex_prompt(
248
467
  append_system_prompt: str = "",
249
468
  allowed_tools: str = "",
250
469
  ) -> str:
470
+ protocol_contract = (
471
+ "NEXO PROTOCOL (MANDATORY):\n"
472
+ "- Before non-trivial analyze/edit/execute/delegate work, call `nexo_task_open(...)`. "
473
+ "If that tool is unavailable, call `nexo_guard_check(...)` and `nexo_cortex_check(...)` first.\n"
474
+ "- For long multi-step or cross-session work, call `nexo_workflow_open(...)` and keep it updated with "
475
+ "`nexo_workflow_update(...)` so resume/replay use durable state instead of guesswork.\n"
476
+ "- If a target file has conditioned learnings or blocking guard rules, review them before any read/edit/delete step, and acknowledge guard before any edit/delete step.\n"
477
+ "- Do not claim done without explicit verification evidence. Close with `nexo_task_close(...)`; if unavailable, capture the change log and state the evidence explicitly.\n"
478
+ "- When a correction changes the canonical rule, capture or supersede the learning instead of leaving contradictory active rules behind."
479
+ )
251
480
  instructions: list[str] = []
481
+ instructions.append(protocol_contract)
252
482
  if append_system_prompt:
253
483
  instructions.append(f"SYSTEM INSTRUCTIONS:\n{append_system_prompt}")
254
484
  if output_format and output_format.lower() == "text":
@@ -269,6 +499,7 @@ def run_automation_prompt(
269
499
  prompt: str,
270
500
  *,
271
501
  backend: str | None = None,
502
+ task_profile: str = "",
272
503
  cwd: str | os.PathLike[str] | None = None,
273
504
  env: dict | None = None,
274
505
  model: str = "",
@@ -284,15 +515,26 @@ def run_automation_prompt(
284
515
  if selected_backend == BACKEND_NONE:
285
516
  raise AutomationBackendUnavailableError("Automation backend is disabled in config.")
286
517
 
518
+ if task_profile:
519
+ profile = resolve_automation_task_profile(task_profile, preferences=prefs)
520
+ selected_backend = profile["backend"] or selected_backend
521
+ if not model:
522
+ model = profile["model"]
523
+ if not reasoning_effort:
524
+ reasoning_effort = profile["reasoning_effort"]
525
+ selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
526
+
287
527
  cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
288
528
  run_env = _headless_env(env)
289
529
  extra_args = list(extra_args or [])
530
+ requested_output_format = output_format or "text"
290
531
  resolved_model, resolved_effort = _resolve_runtime_model_and_effort(
291
532
  selected_backend,
292
533
  model=model,
293
534
  reasoning_effort=reasoning_effort,
294
535
  preferences=prefs,
295
536
  )
537
+ started_at = time.perf_counter()
296
538
 
297
539
  if selected_backend == CLIENT_CLAUDE_CODE:
298
540
  claude_bin = _resolve_claude_cli()
@@ -305,14 +547,13 @@ def run_automation_prompt(
305
547
  cmd.extend(["--model", resolved_model])
306
548
  if resolved_effort:
307
549
  cmd.extend(["--effort", resolved_effort])
308
- if output_format:
309
- cmd.extend(["--output-format", output_format])
550
+ cmd.extend(["--output-format", "json"])
310
551
  if append_system_prompt:
311
552
  cmd.extend(["--append-system-prompt", append_system_prompt])
312
553
  if allowed_tools:
313
554
  cmd.extend(["--allowedTools", allowed_tools])
314
555
  cmd.extend(extra_args)
315
- return subprocess.run(
556
+ result = subprocess.run(
316
557
  cmd,
317
558
  cwd=str(cwd_path),
318
559
  capture_output=True,
@@ -320,6 +561,31 @@ def run_automation_prompt(
320
561
  timeout=timeout,
321
562
  env=run_env,
322
563
  )
564
+ final_stdout, telemetry = _extract_claude_telemetry(
565
+ result.stdout or "",
566
+ requested_output_format=requested_output_format,
567
+ )
568
+ recorded, record_error = _record_automation_run(
569
+ backend=selected_backend,
570
+ task_profile=task_profile,
571
+ model=resolved_model,
572
+ reasoning_effort=resolved_effort,
573
+ cwd=cwd_path,
574
+ output_format=requested_output_format,
575
+ prompt=prompt,
576
+ returncode=result.returncode,
577
+ duration_ms=int((time.perf_counter() - started_at) * 1000),
578
+ telemetry=telemetry,
579
+ )
580
+ stderr = result.stderr or ""
581
+ if not recorded:
582
+ stderr = _append_stderr(stderr, record_error)
583
+ return subprocess.CompletedProcess(
584
+ cmd,
585
+ result.returncode,
586
+ final_stdout,
587
+ stderr,
588
+ )
323
589
 
324
590
  if selected_backend == CLIENT_CODEX:
325
591
  codex_bin = _resolve_codex_cli()
@@ -335,6 +601,7 @@ def run_automation_prompt(
335
601
  "--skip-git-repo-check",
336
602
  "--dangerously-bypass-approvals-and-sandbox",
337
603
  "--ephemeral",
604
+ "--json",
338
605
  "-C",
339
606
  str(cwd_path),
340
607
  "-o",
@@ -364,12 +631,33 @@ def run_automation_prompt(
364
631
  timeout=timeout,
365
632
  env=run_env,
366
633
  )
367
- stdout = output_path.read_text() if output_path.exists() else (result.stdout or "")
634
+ raw_stdout = result.stdout or ""
635
+ stdout = output_path.read_text() if output_path.exists() else raw_stdout
636
+ final_stdout, telemetry = _extract_codex_telemetry(
637
+ raw_stdout,
638
+ final_stdout=stdout,
639
+ model=resolved_model,
640
+ )
641
+ recorded, record_error = _record_automation_run(
642
+ backend=selected_backend,
643
+ task_profile=task_profile,
644
+ model=resolved_model,
645
+ reasoning_effort=resolved_effort,
646
+ cwd=cwd_path,
647
+ output_format=requested_output_format,
648
+ prompt=prompt,
649
+ returncode=result.returncode,
650
+ duration_ms=int((time.perf_counter() - started_at) * 1000),
651
+ telemetry=telemetry,
652
+ )
653
+ stderr = result.stderr or ""
654
+ if not recorded:
655
+ stderr = _append_stderr(stderr, record_error)
368
656
  return subprocess.CompletedProcess(
369
657
  cmd,
370
658
  result.returncode,
371
- stdout,
372
- result.stderr,
659
+ final_stdout,
660
+ stderr,
373
661
  )
374
662
 
375
663
  raise AutomationBackendUnavailableError(f"Unsupported automation backend: {selected_backend}")