nexo-brain 5.3.19 → 5.3.21

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 (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -10
  3. package/package.json +1 -1
  4. package/src/auto_update.py +11 -8
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_episodic 2.py +762 -0
  43. package/src/db/_evolution 2.py +54 -0
  44. package/src/db/_fts 2.py +406 -0
  45. package/src/db/_goal_profiles 2.py +376 -0
  46. package/src/db/_hot_context 2.py +660 -0
  47. package/src/db/_outcomes 2.py +800 -0
  48. package/src/db/_personal_scripts 2.py +582 -0
  49. package/src/db/_sessions 2.py +330 -0
  50. package/src/db/_tasks 2.py +91 -0
  51. package/src/db/_watchers 2.py +173 -0
  52. package/src/doctor/formatters 2.py +52 -0
  53. package/src/doctor/models 2.py +69 -0
  54. package/src/doctor/planes 2.py +87 -0
  55. package/src/doctor/providers/__init__ 2.py +1 -0
  56. package/src/doctor/providers/deep 2.py +367 -0
  57. package/src/evolution_cycle 2.py +519 -0
  58. package/src/hooks/auto_capture 2.py +208 -0
  59. package/src/hooks/caffeinate-guard 2.sh +8 -0
  60. package/src/hooks/capture-session 2.sh +21 -0
  61. package/src/hooks/capture-tool-logs 2.sh +158 -0
  62. package/src/hooks/daily-briefing-check 2.sh +33 -0
  63. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  64. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  65. package/src/hooks/inbox-hook 2.sh +76 -0
  66. package/src/hooks/post-compact 2.sh +152 -0
  67. package/src/hooks/pre-compact 2.sh +169 -0
  68. package/src/hooks/protocol-guardrail 2.sh +10 -0
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  70. package/src/hooks/session-stop 2.sh +52 -0
  71. package/src/kg_populate 2.py +292 -0
  72. package/src/maintenance 2.py +53 -0
  73. package/src/memory_backends 2.py +71 -0
  74. package/src/migrate_embeddings 2.py +124 -0
  75. package/src/nexo_sdk 2.py +103 -0
  76. package/src/observability 2.py +199 -0
  77. package/src/plugin_loader 2.py +217 -0
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +450 -0
  80. package/src/plugins/backup 2.py +127 -0
  81. package/src/plugins/claims_tools 2.py +119 -0
  82. package/src/plugins/cognitive_memory 2.py +609 -0
  83. package/src/plugins/core_rules 2.py +252 -0
  84. package/src/plugins/cortex 2.py +1155 -0
  85. package/src/plugins/entities 2.py +67 -0
  86. package/src/plugins/episodic_memory 2.py +560 -0
  87. package/src/plugins/evolution 2.py +167 -0
  88. package/src/plugins/goal_engine 2.py +142 -0
  89. package/src/plugins/guard 2.py +862 -0
  90. package/src/plugins/impact 2.py +29 -0
  91. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  92. package/src/plugins/media_memory_tools 2.py +98 -0
  93. package/src/plugins/memory_export 2.py +196 -0
  94. package/src/plugins/outcomes 2.py +130 -0
  95. package/src/plugins/personal_scripts 2.py +117 -0
  96. package/src/plugins/preferences 2.py +47 -0
  97. package/src/plugins/protocol 2.py +1449 -0
  98. package/src/plugins/simple_api 2.py +106 -0
  99. package/src/plugins/skills 2.py +341 -0
  100. package/src/plugins/state_watchers 2.py +79 -0
  101. package/src/plugins/update 2.py +986 -0
  102. package/src/plugins/user_state_tools 2.py +43 -0
  103. package/src/plugins/workflow 2.py +588 -0
  104. package/src/protocol_settings 2.py +59 -0
  105. package/src/public_contribution 2.py +466 -0
  106. package/src/public_evolution_queue 2.py +241 -0
  107. package/src/requirements 2.txt +14 -0
  108. package/src/retroactive_learnings 2.py +373 -0
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +331 -0
  111. package/src/rules/migrate 2.py +207 -0
  112. package/src/runtime_power 2.py +874 -0
  113. package/src/script_registry 2.py +1559 -0
  114. package/src/scripts/check-context 2.py +272 -0
  115. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  116. package/src/scripts/deep-sleep/collect 2.py +928 -0
  117. package/src/scripts/deep-sleep/extract 2.py +330 -0
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  119. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  121. package/src/scripts/nexo-agent-run 2.py +75 -0
  122. package/src/scripts/nexo-auto-update 2.py +6 -0
  123. package/src/scripts/nexo-backup 2.sh +25 -0
  124. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  125. package/src/scripts/nexo-catchup 2.py +300 -0
  126. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  127. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  128. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  129. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  130. package/src/scripts/nexo-dashboard 2.sh +29 -0
  131. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  132. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  133. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  134. package/src/scripts/nexo-hook-record 2.py +42 -0
  135. package/src/scripts/nexo-immune 2.py +936 -0
  136. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  137. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  138. package/src/scripts/nexo-install 2.py +6 -0
  139. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  140. package/src/scripts/nexo-learning-validator 2.py +266 -0
  141. package/src/scripts/nexo-migrate 2.py +260 -0
  142. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  144. package/src/scripts/nexo-pre-commit 2.py +120 -0
  145. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  146. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  147. package/src/scripts/nexo-reflection 2.py +256 -0
  148. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  149. package/src/scripts/nexo-sleep 2.py +631 -0
  150. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  151. package/src/scripts/nexo-sync-clients 2.py +16 -0
  152. package/src/scripts/nexo-synthesis 2.py +475 -0
  153. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  154. package/src/scripts/nexo-update 2.sh +306 -0
  155. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  156. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  158. package/src/server 2.py +1296 -0
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  164. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  165. package/src/skills/run-release-final-audit/script 2.py +259 -0
  166. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  167. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  168. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  169. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  170. package/src/skills_runtime 2.py +932 -0
  171. package/src/state_watchers_runtime 2.py +475 -0
  172. package/src/storage_router 2.py +32 -0
  173. package/src/system_catalog 2.py +786 -0
  174. package/src/tools_coordination 2.py +103 -0
  175. package/src/tools_credentials 2.py +68 -0
  176. package/src/tools_drive 2.py +487 -0
  177. package/src/tools_hot_context 2.py +163 -0
  178. package/src/tools_learnings 2.py +612 -0
  179. package/src/tools_menu 2.py +229 -0
  180. package/src/tools_reminders 2.py +88 -0
  181. package/src/tools_reminders_crud 2.py +363 -0
  182. package/src/tools_sessions 2.py +1054 -0
  183. package/src/tools_system_catalog 2.py +19 -0
  184. package/src/tools_task_history 2.py +57 -0
  185. package/src/tools_transcripts 2.py +98 -0
  186. package/src/transcript_utils 2.py +412 -0
  187. package/src/user_context 2.py +46 -0
  188. package/src/user_data_portability 2.py +328 -0
  189. package/src/user_state_model 2.py +170 -0
  190. package/templates/CLAUDE.md 2.template +108 -0
  191. package/templates/CODEX.AGENTS.md 2.template +66 -0
  192. package/templates/launchagents/README 2.md +132 -0
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  194. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  198. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  200. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  205. package/templates/nexo_helper 2.py +301 -0
  206. package/templates/openclaw 2.json +13 -0
  207. package/templates/plugin-template 2.py +40 -0
  208. package/templates/script-template 2.py +59 -0
  209. package/templates/script-template 2.sh +13 -0
  210. package/templates/skill-script-template 2.py +48 -0
  211. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,53 @@
1
+ """Placeholder module for historical `maintenance` runner.
2
+
3
+ HISTORICAL CONTEXT (NEXO-AUDIT-2026-04-11 finding, Learning #194)
4
+ ==================================================================
5
+
6
+ This module previously exposed `check_and_run_overdue()` and a private
7
+ `_run_task()` dispatcher that walked the `maintenance_schedule` table and
8
+ executed cognitive decay, synthesis, self audit, weight learning, somatic
9
+ decay, somatic projection, drive decay and graph maintenance as needed.
10
+
11
+ None of that code was ever called from anywhere. A repository-wide grep
12
+ for `check_and_run_overdue`, `from maintenance`, `import maintenance` and
13
+ `maintenance.check` produced zero hits during the 2026-04-11 audit. Each
14
+ of those tasks actually runs from its own LaunchAgent via its own script
15
+ in `src/scripts/` (e.g. `nexo-cognitive-decay.py`, `nexo-daily-self-audit
16
+ .py`, `nexo-evolution-run.py`), not through this dispatcher. The
17
+ `maintenance_schedule` table in SQLite is still populated by migrations
18
+ (see `db/_schema.py::_m9_maintenance_schedule` and the drive_decay
19
+ registration) but is effectively dead data: nothing reads it.
20
+
21
+ Why the dispatcher was removed
22
+ ------------------------------
23
+ The dead dispatcher was actively misleading: developers reading the code
24
+ could reasonably conclude that adding a row to `maintenance_schedule`
25
+ would cause the named task to run. It would not. That false contract
26
+ was the root of a near-miss during the Item 9 fix of the 2026-04-11
27
+ audit, where the first plan was to register a new `read_token_cleanup`
28
+ task via this mechanism before discovering that the mechanism is never
29
+ invoked. See `src/db/_reminders.py::_purge_expired_read_tokens_if_due`
30
+ for the opportunistic-cleanup pattern that replaced that initial plan.
31
+
32
+ What to do if you need scheduled maintenance
33
+ --------------------------------------------
34
+ Do NOT reintroduce a dispatcher in this module. Pick one of:
35
+
36
+ * Add your work to an existing LaunchAgent script under
37
+ `src/scripts/` that already runs on the cadence you need.
38
+ * Register a new personal script via `nexo_personal_script_create` and
39
+ let the schedule/LaunchAgent system handle it.
40
+ * Run the cleanup opportunistically inside the hot path, throttled
41
+ by wall-clock (see `_purge_expired_read_tokens_if_due`).
42
+
43
+ What happens to the `maintenance_schedule` table
44
+ -------------------------------------------------
45
+ The table is intentionally left in place. Removing it would require a
46
+ destructive migration for every installed user with no benefit — the
47
+ rows do no harm, cost a few KB each, and their removal is deferred to a
48
+ future cleanup pass when migration numbering is renegotiated.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ __all__: list[str] = []
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ """Explicit backend registry for memory expansion layers.
4
+
5
+ NEXO's historical memory system is still heavily SQLite-shaped, but newer layers
6
+ should not keep backend assumptions implicit forever. This module introduces a
7
+ small registry/contract that expansion surfaces can use today while SQLite
8
+ remains the default backend.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ import os
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class MemoryBackendInfo:
17
+ key: str
18
+ label: str
19
+ description: str
20
+ supports: tuple[str, ...]
21
+ maturity: str = "stable"
22
+
23
+
24
+ _REGISTRY: dict[str, MemoryBackendInfo] = {}
25
+
26
+
27
+ def register_backend(info: MemoryBackendInfo) -> None:
28
+ _REGISTRY[info.key] = info
29
+
30
+
31
+ def active_backend_key() -> str:
32
+ return (os.environ.get("NEXO_MEMORY_BACKEND", "sqlite") or "sqlite").strip().lower()
33
+
34
+
35
+ def get_backend(key: str = "") -> MemoryBackendInfo:
36
+ selected = (key or active_backend_key()).strip().lower()
37
+ return _REGISTRY.get(selected, _REGISTRY["sqlite"])
38
+
39
+
40
+ def list_backends() -> list[dict]:
41
+ active = active_backend_key()
42
+ results = []
43
+ for key in sorted(_REGISTRY):
44
+ info = _REGISTRY[key]
45
+ item = {
46
+ "key": info.key,
47
+ "label": info.label,
48
+ "description": info.description,
49
+ "supports": list(info.supports),
50
+ "maturity": info.maturity,
51
+ "active": info.key == active,
52
+ }
53
+ results.append(item)
54
+ return results
55
+
56
+
57
+ register_backend(
58
+ MemoryBackendInfo(
59
+ key="sqlite",
60
+ label="SQLite + FTS5",
61
+ description="Local-first default backend used by NEXO runtime surfaces.",
62
+ supports=(
63
+ "cognitive_core",
64
+ "claims",
65
+ "media_memory",
66
+ "user_state",
67
+ "memory_export",
68
+ "auto_flush",
69
+ ),
70
+ )
71
+ )
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Migrate cognitive.db embeddings between models.
4
+
5
+ Usage:
6
+ python migrate_embeddings.py upgrade # 384 → 768 (bge-small → bge-base)
7
+ python migrate_embeddings.py rollback # Restore from backup
8
+ python migrate_embeddings.py verify # Check current embedding dims
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import sqlite3
14
+ import sys
15
+ import time
16
+ import numpy as np
17
+
18
+ NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
19
+ _data_dir = os.path.join(NEXO_HOME, "data")
20
+ os.makedirs(_data_dir, exist_ok=True)
21
+ DB_PATH = os.path.join(_data_dir, "cognitive.db")
22
+ BACKUP_PATH = DB_PATH + ".bak-384dims-pre-upgrade"
23
+
24
+ MODELS = {
25
+ "small": ("BAAI/bge-small-en-v1.5", 384),
26
+ "base": ("BAAI/bge-base-en-v1.5", 768),
27
+ }
28
+
29
+
30
+ def verify():
31
+ """Check current embedding dimensions in the database."""
32
+ conn = sqlite3.connect(DB_PATH)
33
+ try:
34
+ for table in ["stm_memories", "ltm_memories"]:
35
+ count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
36
+ if count == 0:
37
+ print(f" {table}: {count} rows (empty)")
38
+ continue
39
+ row = conn.execute(f"SELECT embedding FROM {table} LIMIT 1").fetchone()
40
+ vec = np.frombuffer(row[0], dtype=np.float32)
41
+ print(f" {table}: {count} rows, embedding dim = {len(vec)}")
42
+ finally:
43
+ conn.close()
44
+
45
+
46
+ def upgrade():
47
+ """Re-embed all memories from bge-small (384) to bge-base (768)."""
48
+ from fastembed import TextEmbedding
49
+
50
+ # Verify current state
51
+ print("Current state:")
52
+ verify()
53
+
54
+ # Verify backup exists
55
+ if not os.path.exists(BACKUP_PATH):
56
+ print(f"\nCreating backup at {BACKUP_PATH}")
57
+ shutil.copy2(DB_PATH, BACKUP_PATH)
58
+ else:
59
+ print(f"\nBackup already exists at {BACKUP_PATH}")
60
+
61
+ # Load new model
62
+ model_name, expected_dim = MODELS["base"]
63
+ print(f"\nLoading {model_name}...")
64
+ model = TextEmbedding(model_name)
65
+
66
+ conn = sqlite3.connect(DB_PATH)
67
+ try:
68
+ for table in ["stm_memories", "ltm_memories"]:
69
+ rows = conn.execute(f"SELECT id, content FROM {table}").fetchall()
70
+ if not rows:
71
+ print(f"\n{table}: empty, skipping")
72
+ continue
73
+
74
+ print(f"\n{table}: re-embedding {len(rows)} memories...")
75
+ t0 = time.time()
76
+
77
+ # Batch embed for speed
78
+ contents = [r[1] for r in rows]
79
+ ids = [r[0] for r in rows]
80
+
81
+ embeddings = list(model.embed(contents))
82
+
83
+ for mem_id, emb in zip(ids, embeddings):
84
+ blob = np.array(emb, dtype=np.float32).tobytes()
85
+ conn.execute(f"UPDATE {table} SET embedding = ? WHERE id = ?", (blob, mem_id))
86
+
87
+ conn.commit()
88
+ elapsed = time.time() - t0
89
+ print(f" Done: {len(rows)} memories in {elapsed:.1f}s ({elapsed/len(rows)*1000:.0f}ms/memory)")
90
+ finally:
91
+ conn.close()
92
+
93
+ print("\nAfter upgrade:")
94
+ verify()
95
+ print("\nUpgrade complete. Run 'verify' to confirm.")
96
+
97
+
98
+ def rollback():
99
+ """Restore database from pre-upgrade backup."""
100
+ if not os.path.exists(BACKUP_PATH):
101
+ print(f"ERROR: Backup not found at {BACKUP_PATH}")
102
+ sys.exit(1)
103
+
104
+ print(f"Restoring from {BACKUP_PATH}...")
105
+ shutil.copy2(BACKUP_PATH, DB_PATH)
106
+ print("Restored. Current state:")
107
+ verify()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ if len(sys.argv) < 2:
112
+ print("Usage: python migrate_embeddings.py [upgrade|rollback|verify]")
113
+ sys.exit(1)
114
+
115
+ cmd = sys.argv[1]
116
+ if cmd == "upgrade":
117
+ upgrade()
118
+ elif cmd == "rollback":
119
+ rollback()
120
+ elif cmd == "verify":
121
+ verify()
122
+ else:
123
+ print(f"Unknown command: {cmd}")
124
+ sys.exit(1)
@@ -0,0 +1,103 @@
1
+ """Minimal Python SDK for the public NEXO mental model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class NEXOClient:
12
+ """Tiny Python wrapper around `nexo call` for common public operations."""
13
+
14
+ nexo_bin: str = "nexo"
15
+
16
+ def call(self, tool: str, payload: dict | None = None) -> dict | list | str:
17
+ result = subprocess.run(
18
+ [
19
+ self.nexo_bin,
20
+ "call",
21
+ tool,
22
+ "--input",
23
+ json.dumps(payload or {}, ensure_ascii=False),
24
+ "--json-output",
25
+ ],
26
+ capture_output=True,
27
+ text=True,
28
+ check=False,
29
+ )
30
+ if result.returncode != 0:
31
+ raise RuntimeError((result.stderr or result.stdout or f"{tool} failed").strip())
32
+ text = (result.stdout or "").strip()
33
+ if not text:
34
+ return {}
35
+ try:
36
+ return json.loads(text)
37
+ except json.JSONDecodeError:
38
+ return {"result": text}
39
+
40
+ def remember(
41
+ self,
42
+ content: str,
43
+ *,
44
+ title: str = "",
45
+ domain: str = "",
46
+ source_type: str = "note",
47
+ tags: str = "",
48
+ bypass_gate: bool = True,
49
+ ) -> dict | list | str:
50
+ return self.call(
51
+ "nexo_remember",
52
+ {
53
+ "content": content,
54
+ "title": title,
55
+ "domain": domain,
56
+ "source_type": source_type,
57
+ "tags": tags,
58
+ "bypass_gate": bypass_gate,
59
+ },
60
+ )
61
+
62
+ def recall(self, query: str, *, days: int = 30) -> dict | list | str:
63
+ return self.call("nexo_memory_recall", {"query": query, "days": days})
64
+
65
+ def consolidate(
66
+ self,
67
+ *,
68
+ max_insights: int = 12,
69
+ threshold: float = 0.9,
70
+ dry_run: bool = False,
71
+ ) -> dict | list | str:
72
+ return self.call(
73
+ "nexo_consolidate",
74
+ {
75
+ "max_insights": max_insights,
76
+ "threshold": threshold,
77
+ "dry_run": dry_run,
78
+ },
79
+ )
80
+
81
+ def run_workflow(
82
+ self,
83
+ sid: str,
84
+ goal: str,
85
+ *,
86
+ steps: list[dict] | str,
87
+ goal_id: str = "",
88
+ shared_state: dict | str | None = None,
89
+ owner: str = "",
90
+ idempotency_key: str = "",
91
+ ) -> dict | list | str:
92
+ return self.call(
93
+ "nexo_run_workflow",
94
+ {
95
+ "sid": sid,
96
+ "goal": goal,
97
+ "steps": steps if isinstance(steps, str) else json.dumps(steps, ensure_ascii=False),
98
+ "goal_id": goal_id,
99
+ "shared_state": shared_state if isinstance(shared_state, str) else json.dumps(shared_state or {}, ensure_ascii=False),
100
+ "owner": owner,
101
+ "idempotency_key": idempotency_key,
102
+ },
103
+ )
@@ -0,0 +1,199 @@
1
+ """OpenTelemetry observability for NEXO Brain — Fase 5 item 2.
2
+
3
+ Closes Fase 5 item 2 of NEXO-AUDIT-2026-04-11. The audit asked for
4
+ observability with OTEL / Langfuse / Phoenix integration. This module
5
+ is the soft-import host: it adds tracing primitives that NEXO can call
6
+ unconditionally, but only emit real spans when:
7
+
8
+ 1. The opentelemetry-api package is installed in the user's environment
9
+ (`pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp`).
10
+ 2. The OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set
11
+ (or OTEL_SERVICE_NAME is set, indicating the user already
12
+ bootstrapped a tracer provider externally).
13
+
14
+ Otherwise every primitive is a no-op so the runtime cost on installs
15
+ without OTEL is exactly zero (no try/except per call site, no extra
16
+ import time on the hot path).
17
+
18
+ Why this design:
19
+ - NEXO core does NOT require opentelemetry as a hard dependency.
20
+ The 10k+ active users would have an extra 30 MB in their venv with
21
+ no benefit unless they opted in.
22
+ - Users who DO want telemetry get a single env var to flip on.
23
+ - The shape (span name, attributes, status) follows the OpenTelemetry
24
+ semantic conventions for "ai.tool" so dashboards in Langfuse,
25
+ Arize Phoenix, Honeycomb, Jaeger, and Grafana Tempo all render
26
+ NEXO traces with their built-in views.
27
+
28
+ Usage:
29
+
30
+ from observability import tool_span
31
+
32
+ with tool_span("nexo_heartbeat", attributes={"sid": sid}) as span:
33
+ result = handle_heartbeat(sid, task)
34
+ if span is not None:
35
+ span.set_attribute("nexo.heartbeat.task", task[:200])
36
+ return result
37
+
38
+ The `with` block always works whether or not OTEL is installed; the
39
+ span is None when telemetry is disabled.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ from contextlib import contextmanager
46
+ from typing import Any, Iterator
47
+
48
+
49
+ # ── OTEL availability detection ──────────────────────────────────────────
50
+
51
+
52
+ _otel_available: bool | None = None
53
+ _tracer = None # cached after first use
54
+
55
+
56
+ def _truthy_env(var: str) -> bool:
57
+ return bool((os.environ.get(var) or "").strip())
58
+
59
+
60
+ def is_otel_enabled() -> bool:
61
+ """Return True if OpenTelemetry is installed AND a configuration is set.
62
+
63
+ The two activation conditions are:
64
+ - opentelemetry-api importable
65
+ - One of OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_SERVICE_NAME is set
66
+ (the latter signals an externally-bootstrapped tracer provider).
67
+
68
+ Cached after first call so the hot path is a single bool lookup.
69
+ """
70
+ global _otel_available
71
+ if _otel_available is not None:
72
+ return _otel_available
73
+
74
+ if not (_truthy_env("OTEL_EXPORTER_OTLP_ENDPOINT") or _truthy_env("OTEL_SERVICE_NAME")):
75
+ _otel_available = False
76
+ return False
77
+
78
+ try:
79
+ import opentelemetry # noqa: F401
80
+ from opentelemetry import trace # noqa: F401
81
+ except ImportError:
82
+ _otel_available = False
83
+ return False
84
+
85
+ _otel_available = True
86
+ return True
87
+
88
+
89
+ def get_tracer():
90
+ """Return a cached opentelemetry.trace.Tracer or None when OTEL is off.
91
+
92
+ Lazy: only constructs the tracer the first time it is needed so
93
+ installs without OTEL never pay any import cost.
94
+ """
95
+ global _tracer
96
+ if _tracer is not None:
97
+ return _tracer
98
+ if not is_otel_enabled():
99
+ return None
100
+ try:
101
+ from opentelemetry import trace
102
+ _tracer = trace.get_tracer("nexo-brain")
103
+ return _tracer
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ # ── span context manager ─────────────────────────────────────────────────
109
+
110
+
111
+ @contextmanager
112
+ def tool_span(
113
+ name: str,
114
+ *,
115
+ attributes: dict[str, Any] | None = None,
116
+ ) -> Iterator[Any]:
117
+ """Context manager that emits an OTEL span when telemetry is enabled.
118
+
119
+ The span name follows the OTEL semantic convention prefix "ai.tool."
120
+ so dashboards that already group by ai.tool.* automatically pick
121
+ NEXO traces up.
122
+
123
+ On success: status = OK.
124
+ On exception: status = ERROR with the exception message recorded as
125
+ an attribute, and the exception is re-raised so callers see it.
126
+
127
+ When telemetry is disabled, the context manager yields None and
128
+ does nothing else — the cost is one is_otel_enabled() bool lookup
129
+ plus the empty `with` block, which the Python compiler optimizes.
130
+
131
+ Args:
132
+ name: short tool name. The full span name becomes "ai.tool.<name>".
133
+ attributes: optional dict of OTEL attributes to set on the span.
134
+ """
135
+ if not is_otel_enabled():
136
+ yield None
137
+ return
138
+
139
+ tracer = get_tracer()
140
+ if tracer is None:
141
+ yield None
142
+ return
143
+
144
+ try:
145
+ from opentelemetry import trace as _trace
146
+ span_name = f"ai.tool.{name}" if not name.startswith("ai.tool.") else name
147
+ with tracer.start_as_current_span(span_name) as span:
148
+ try:
149
+ if attributes:
150
+ for key, value in attributes.items():
151
+ try:
152
+ span.set_attribute(key, value)
153
+ except Exception:
154
+ # Some values (e.g. dicts) are not OTEL-compatible.
155
+ try:
156
+ span.set_attribute(key, str(value)[:1000])
157
+ except Exception:
158
+ pass
159
+ yield span
160
+ span.set_status(_trace.Status(_trace.StatusCode.OK))
161
+ except Exception as exc:
162
+ try:
163
+ span.record_exception(exc)
164
+ span.set_status(
165
+ _trace.Status(_trace.StatusCode.ERROR, str(exc)[:300])
166
+ )
167
+ except Exception:
168
+ pass
169
+ raise
170
+ except Exception:
171
+ # If anything goes wrong with the OTEL machinery itself, fall
172
+ # back to a no-op so the caller never sees a telemetry-induced
173
+ # exception. The original work still runs.
174
+ yield None
175
+
176
+
177
+ def record_tool_attributes(span: Any, attributes: dict[str, Any]) -> None:
178
+ """Set OTEL attributes on a span if it is non-None and OTEL is enabled.
179
+
180
+ Convenience helper so callers do not need to write the `if span is
181
+ not None` guard at every call site.
182
+ """
183
+ if span is None:
184
+ return
185
+ for key, value in (attributes or {}).items():
186
+ try:
187
+ span.set_attribute(key, value)
188
+ except Exception:
189
+ try:
190
+ span.set_attribute(key, str(value)[:1000])
191
+ except Exception:
192
+ pass
193
+
194
+
195
+ def _reset_for_tests() -> None:
196
+ """Test-only helper to reset the cached availability + tracer."""
197
+ global _otel_available, _tracer
198
+ _otel_available = None
199
+ _tracer = None