nexo-brain 3.1.8 → 3.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "3.1.8",
3
+ "version": "3.2.0",
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
@@ -21,6 +21,7 @@
21
21
  Start here:
22
22
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
23
23
  - [Workflow quickstart](docs/workflows-quickstart.md)
24
+ - [Recent memory fallbacks + live system catalog](docs/recent-memory-fallbacks-and-system-catalog.md)
24
25
  - [Supported client guides](docs/integrations/cursor.md)
25
26
  - [Docker setup](docs/docker-setup.md)
26
27
  - [Architecture visuals](docs/architecture-visuals.md)
@@ -79,6 +80,13 @@ Versions `3.0.0` and `3.0.1` close the next execution gap:
79
80
  - `cost_per_solved_task`
80
81
  - SDK/API/quickstart surface
81
82
 
83
+ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
84
+
85
+ - recent operational continuity is now first-class through `hot context` and `recent events`
86
+ - the runtime can build a reusable pre-action bundle instead of reconstructing the last few hours from diaries and durable recall only
87
+ - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
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
+
82
90
  ### Client Capability Matrix
83
91
 
84
92
  | Capability | Claude Code | Codex | Claude Desktop |
@@ -340,6 +348,15 @@ NEXO Brain provides **150+ MCP tools** across 23 categories. These features impl
340
348
  | **Auto-Merge Duplicates** | Batch cosine deduplication during the 03:00 sleep cycle. Respects sibling discrimination — similar memories about different contexts are kept separate. |
341
349
  | **Memory Dreaming** | Discovers hidden connections between recent memories during the 03:00 sleep cycle and now feeds a 60-day long-horizon Deep Sleep blend, so older patterns can reappear when they become relevant again. |
342
350
 
351
+ ### Operational Continuity
352
+
353
+ | Feature | What It Does |
354
+ |---------|-------------|
355
+ | **Hot Context 24h** | Keeps active topics, blockers, and waiting states fresh across sessions, clients, cron ticks, and channel changes. This is the shared recent-memory substrate for operational continuity. |
356
+ | **Pre-Action Context Bundle** | Loads recent contexts, recent events, related reminders, and related followups before acting, so continuity is explicit instead of prompt-only. |
357
+ | **Transcript Fallback** | When recent-memory capture is thin or missing, NEXO can now search and read recent Claude Code / Codex transcripts directly through MCP instead of pretending the conversation is lost. |
358
+ | **Live System Catalog** | NEXO can now inspect its own current surface — core tools, plugin tools, skills, scripts, crons, projects, and artifacts — through a live catalog derived from canonical sources at read time. |
359
+
343
360
  ### Retrieval
344
361
 
345
362
  | Feature | What It Does |
@@ -724,10 +741,14 @@ Public entry points for the mental model now stay intentionally small:
724
741
  - `nexo_memory_recall`
725
742
  - `nexo_consolidate`
726
743
  - `nexo_run_workflow`
744
+ - `nexo_pre_action_context`
745
+ - `nexo_transcript_search`
746
+ - `nexo_system_catalog`
727
747
 
728
748
  If you want the shell or Python wrappers instead of raw MCP tools:
729
749
  - [docs/quickstart-5-minutes.md](docs/quickstart-5-minutes.md)
730
750
  - [docs/memory-classes.md](docs/memory-classes.md)
751
+ - [docs/recent-memory-fallbacks-and-system-catalog.md](docs/recent-memory-fallbacks-and-system-catalog.md)
731
752
  - [docs/sdk-python.md](docs/sdk-python.md)
732
753
  - [docs/reference-verticals.md](docs/reference-verticals.md)
733
754
  - [compare/README.md](compare/README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "3.1.8",
3
+ "version": "3.2.0",
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",
@@ -1186,27 +1186,38 @@ def _source_repo_status(repo_dir: Path) -> dict:
1186
1186
  }
1187
1187
 
1188
1188
 
1189
+ def _discover_runtime_root_python_modules(base_dir: Path) -> list[str]:
1190
+ """Return every top-level runtime `.py` module in the source/runtime root."""
1191
+ if not base_dir.is_dir():
1192
+ return []
1193
+ modules: list[str] = []
1194
+ for item in sorted(base_dir.iterdir(), key=lambda path: path.name):
1195
+ if not item.is_file() or item.suffix != ".py":
1196
+ continue
1197
+ if item.name.startswith(".") or item.name == "__init__.py":
1198
+ continue
1199
+ modules.append(item.name)
1200
+ return modules
1201
+
1202
+
1203
+ def _runtime_flat_files(base_dir: Path) -> list[str]:
1204
+ ordered: list[str] = []
1205
+ seen: set[str] = set()
1206
+ for name in _discover_runtime_root_python_modules(base_dir) + ["requirements.txt", "package.json", "version.json"]:
1207
+ if name in seen:
1208
+ continue
1209
+ seen.add(name)
1210
+ ordered.append(name)
1211
+ return ordered
1212
+
1213
+
1189
1214
  def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1190
1215
  timestamp = time.strftime("%Y-%m-%d-%H%M%S")
1191
1216
  backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
1192
1217
  backup_dir.mkdir(parents=True, exist_ok=True)
1193
1218
 
1194
1219
  code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts", "doctor", "skills-core"]
1195
- flat_files = [
1196
- "server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
1197
- "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
1198
- "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
1199
- "client_sync.py",
1200
- "client_preferences.py", "agent_runner.py", "bootstrap_docs.py",
1201
- "hook_guardrails.py", "protocol_settings.py", "public_evolution_queue.py",
1202
- "auto_update.py", "tools_sessions.py", "tools_coordination.py",
1203
- "tools_hot_context.py",
1204
- "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1205
- "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
1206
- "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
1207
- "public_contribution.py",
1208
- "cron_recovery.py", "runtime_power.py", "requirements.txt", "package.json", "version.json",
1209
- ]
1220
+ flat_files = _runtime_flat_files(dest)
1210
1221
  for name in code_dirs:
1211
1222
  src = dest / name
1212
1223
  if src.is_dir():
@@ -1244,21 +1255,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1244
1255
  import shutil
1245
1256
 
1246
1257
  packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks"]
1247
- flat_files = [
1248
- "server.py", "plugin_loader.py", "knowledge_graph.py", "kg_populate.py",
1249
- "maintenance.py", "storage_router.py", "claim_graph.py", "hnsw_index.py",
1250
- "evolution_cycle.py", "migrate_embeddings.py", "auto_close_sessions.py",
1251
- "client_sync.py",
1252
- "client_preferences.py", "agent_runner.py", "bootstrap_docs.py",
1253
- "hook_guardrails.py", "protocol_settings.py", "public_evolution_queue.py",
1254
- "auto_update.py", "tools_sessions.py", "tools_coordination.py",
1255
- "tools_hot_context.py",
1256
- "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1257
- "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
1258
- "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
1259
- "public_contribution.py",
1260
- "cron_recovery.py", "runtime_power.py", "requirements.txt",
1261
- ]
1258
+ flat_files = _runtime_flat_files(src_dir)
1262
1259
  copied_packages = 0
1263
1260
  copied_files = 0
1264
1261
 
@@ -19,6 +19,7 @@ import sys
19
19
  from collections import Counter
20
20
  from datetime import datetime, timedelta
21
21
  from pathlib import Path
22
+ import transcript_utils as _transcripts
22
23
 
23
24
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
24
25
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", ""))
@@ -64,196 +65,22 @@ def _session_identifier(client: str, session_file: str) -> str:
64
65
 
65
66
  def find_claude_session_files() -> list[Path]:
66
67
  """Find Claude Code session JSONL files under ~/.claude/projects."""
67
- claude_dir = Path.home() / ".claude" / "projects"
68
- if not claude_dir.exists():
69
- return []
70
- return sorted(claude_dir.rglob("*.jsonl"))
68
+ return _transcripts.find_claude_session_files()
71
69
 
72
70
 
73
71
  def find_codex_session_files() -> list[Path]:
74
72
  """Find Codex session JSONL files under ~/.codex/sessions and archived_sessions."""
75
- roots = [
76
- Path.home() / ".codex" / "sessions",
77
- Path.home() / ".codex" / "archived_sessions",
78
- ]
79
- files: list[Path] = []
80
- seen: set[str] = set()
81
- for root in roots:
82
- if not root.exists():
83
- continue
84
- for jsonl in sorted(root.rglob("*.jsonl")):
85
- key = jsonl.name
86
- if key in seen:
87
- continue
88
- seen.add(key)
89
- files.append(jsonl)
90
- return files
73
+ return _transcripts.find_codex_session_files()
91
74
 
92
75
 
93
76
  def extract_claude_session(jsonl_path: Path) -> dict | None:
94
77
  """Extract clean transcript from a Claude Code JSONL session."""
95
- messages = []
96
- tool_uses = []
97
- user_msg_count = 0
98
-
99
- try:
100
- with open(jsonl_path, "r") as f:
101
- for line_no, line in enumerate(f, 1):
102
- line = line.strip()
103
- if not line:
104
- continue
105
- try:
106
- d = json.loads(line)
107
- except json.JSONDecodeError:
108
- continue
109
-
110
- msg_type = d.get("type")
111
-
112
- # User messages
113
- if msg_type == "user":
114
- content = d.get("message", {}).get("content", "")
115
- if isinstance(content, str) and content.strip():
116
- if content.startswith("<system-reminder>"):
117
- continue
118
- messages.append({
119
- "role": "user",
120
- "index": line_no,
121
- "text": _redact_sensitive(content[:5000]),
122
- "uuid": d.get("uuid", "")
123
- })
124
- user_msg_count += 1
125
-
126
- # Assistant messages
127
- elif msg_type in ("message", "assistant"):
128
- msg = d.get("message", {})
129
- content_blocks = msg.get("content", [])
130
- text_parts = []
131
- for block in content_blocks:
132
- if isinstance(block, dict):
133
- if block.get("type") == "text":
134
- text_parts.append(block.get("text", ""))
135
- elif block.get("type") == "tool_use":
136
- tool_input = block.get("input", {})
137
- raw_file = (
138
- tool_input.get("file_path", "")
139
- or str(tool_input.get("command", ""))[:100]
140
- ) if isinstance(tool_input, dict) else ""
141
- tool_uses.append({
142
- "tool": block.get("name", ""),
143
- "input_keys": list(tool_input.keys()) if isinstance(tool_input, dict) else [],
144
- "file": _redact_sensitive(raw_file)
145
- })
146
- if text_parts:
147
- combined = "\n".join(text_parts)[:5000]
148
- combined = _redact_sensitive(combined)
149
- messages.append({
150
- "role": "assistant",
151
- "index": line_no,
152
- "text": combined
153
- })
154
-
155
- except Exception as e:
156
- print(f" [collect] Error reading {jsonl_path}: {e}", file=sys.stderr)
157
- return None
158
-
159
- if user_msg_count < MIN_USER_MESSAGES:
160
- return None
161
-
162
- return {
163
- "client": "claude_code",
164
- "session_file": _session_identifier("claude_code", jsonl_path.name),
165
- "display_name": jsonl_path.name,
166
- "session_path": str(jsonl_path),
167
- "message_count": len(messages),
168
- "user_message_count": user_msg_count,
169
- "tool_use_count": len(tool_uses),
170
- "messages": messages,
171
- "tool_uses": tool_uses,
172
- "source": "claude_projects",
173
- }
78
+ return _transcripts.extract_claude_session(jsonl_path)
174
79
 
175
80
 
176
81
  def extract_codex_session(jsonl_path: Path) -> dict | None:
177
82
  """Extract clean transcript from a Codex JSONL session."""
178
- messages = []
179
- tool_uses = []
180
- user_msg_count = 0
181
- session_meta: dict = {}
182
-
183
- try:
184
- with open(jsonl_path, "r") as f:
185
- for line_no, line in enumerate(f, 1):
186
- line = line.strip()
187
- if not line:
188
- continue
189
- try:
190
- d = json.loads(line)
191
- except json.JSONDecodeError:
192
- continue
193
-
194
- item_type = d.get("type")
195
- payload = d.get("payload", {})
196
-
197
- if item_type == "session_meta" and isinstance(payload, dict):
198
- session_meta = payload
199
- continue
200
-
201
- if item_type == "event_msg" and isinstance(payload, dict) and payload.get("type") == "user_message":
202
- content = str(payload.get("message", "") or "").strip()
203
- if not content or content.startswith("<environment_context>"):
204
- continue
205
- messages.append({
206
- "role": "user",
207
- "index": line_no,
208
- "text": _redact_sensitive(content[:5000]),
209
- })
210
- user_msg_count += 1
211
- continue
212
-
213
- if item_type == "response_item" and isinstance(payload, dict):
214
- response_type = payload.get("type")
215
- role = payload.get("role")
216
- if response_type == "message" and role == "assistant":
217
- text_parts = []
218
- for block in payload.get("content", []) or []:
219
- if isinstance(block, dict) and block.get("type") == "output_text":
220
- text_parts.append(str(block.get("text", "")))
221
- combined = "\n".join(part for part in text_parts if part).strip()
222
- if combined:
223
- messages.append({
224
- "role": "assistant",
225
- "index": line_no,
226
- "text": _redact_sensitive(combined[:5000]),
227
- })
228
- elif response_type == "function_call":
229
- tool_uses.append({
230
- "tool": payload.get("name", ""),
231
- "input_keys": [],
232
- "file": _redact_sensitive(str(payload.get("arguments", ""))[:100]),
233
- })
234
-
235
- except Exception as e:
236
- print(f" [collect] Error reading {jsonl_path}: {e}", file=sys.stderr)
237
- return None
238
-
239
- if user_msg_count < MIN_USER_MESSAGES:
240
- return None
241
-
242
- return {
243
- "client": "codex",
244
- "session_file": _session_identifier("codex", jsonl_path.name),
245
- "display_name": jsonl_path.name,
246
- "session_path": str(jsonl_path),
247
- "message_count": len(messages),
248
- "user_message_count": user_msg_count,
249
- "tool_use_count": len(tool_uses),
250
- "messages": messages,
251
- "tool_uses": tool_uses,
252
- "source": session_meta.get("source", "codex"),
253
- "cwd": session_meta.get("cwd", ""),
254
- "originator": session_meta.get("originator", ""),
255
- "session_uid": session_meta.get("id", ""),
256
- }
83
+ return _transcripts.extract_codex_session(jsonl_path)
257
84
 
258
85
 
259
86
  def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]:
@@ -262,28 +89,7 @@ def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]
262
89
  Uses a watermark approach: deep sleep tracks the last processed timestamp
263
90
  so nothing is missed regardless of when sessions happen (day, night, etc.).
264
91
  """
265
- since_dt = datetime.fromisoformat(since_iso)
266
- until_dt = datetime.fromisoformat(until_iso) if until_iso else datetime.now()
267
-
268
- sessions = []
269
- transcript_files: list[tuple[str, Path]] = [
270
- ("claude_code", path) for path in find_claude_session_files()
271
- ] + [
272
- ("codex", path) for path in find_codex_session_files()
273
- ]
274
- for client, session_file in transcript_files:
275
- try:
276
- mtime = datetime.fromtimestamp(session_file.stat().st_mtime)
277
- except OSError:
278
- continue
279
- if not (since_dt < mtime <= until_dt):
280
- continue
281
- session = extract_codex_session(session_file) if client == "codex" else extract_claude_session(session_file)
282
- if session:
283
- session["modified"] = mtime.isoformat()
284
- sessions.append(session)
285
- sessions.sort(key=lambda s: s["modified"])
286
- return sessions
92
+ return _transcripts.collect_transcripts_since(since_iso, until_iso)
287
93
 
288
94
 
289
95
  # ── Database queries ──────────────────────────────────────────────────────
package/src/server.py CHANGED
@@ -23,6 +23,15 @@ from tools_hot_context import (
23
23
  handle_recent_context_resolve,
24
24
  handle_hot_context_list,
25
25
  )
26
+ from tools_transcripts import (
27
+ handle_transcript_recent,
28
+ handle_transcript_search,
29
+ handle_transcript_read,
30
+ )
31
+ from tools_system_catalog import (
32
+ handle_system_catalog,
33
+ handle_tool_explain,
34
+ )
26
35
  from user_context import get_context as _get_ctx
27
36
  from tools_coordination import (
28
37
  handle_track, handle_untrack, handle_files,
@@ -209,6 +218,8 @@ mcp = FastMCP(
209
218
  "- **Delegate:** prefer direct. If needed: `nexo_context_packet(area)` + guard + 'if unsure STOP'\n"
210
219
  "- **Memory:** `nexo_recall` searches all. For fresh 24h continuity use `nexo_pre_action_context(query='...')` before acting and "
211
220
  "`nexo_recent_context_capture(...)` / `nexo_recent_context_resolve(...)` for important ongoing threads. "
221
+ "If that is not enough, use `nexo_transcript_search(...)` / `nexo_transcript_read(...)` as the raw fallback to full conversations. "
222
+ "Use `nexo_system_catalog(...)` / `nexo_tool_explain(...)` when you need the live map of NEXO itself. "
212
223
  "Capture: errors→`nexo_learning_add`, prefs, entities, decisions\n"
213
224
  "- **Change log:** `nexo_task_close` should be the default closure path. If you bypass it, call `nexo_change_log(...)` after production edits. NOT for config dir\n"
214
225
  "- **Diary:** When user signals end of session (any language, any style — 'bye', 'done', 'cierro', etc.), "
@@ -380,6 +391,36 @@ def nexo_hot_context_list(hours: int = 24, limit: int = 10, state: str = "") ->
380
391
  return handle_hot_context_list(hours, limit, state)
381
392
 
382
393
 
394
+ @mcp.tool
395
+ def nexo_transcript_recent(hours: int = 24, client: str = "", limit: int = 10) -> str:
396
+ """List recent Claude Code / Codex transcripts visible to NEXO."""
397
+ return handle_transcript_recent(hours, client, limit)
398
+
399
+
400
+ @mcp.tool
401
+ def nexo_transcript_search(query: str = "", hours: int = 24, client: str = "", limit: int = 10) -> str:
402
+ """Search recent transcripts directly when recall/hot-context are not enough."""
403
+ return handle_transcript_search(query, hours, client, limit)
404
+
405
+
406
+ @mcp.tool
407
+ def nexo_transcript_read(session_ref: str = "", transcript_path: str = "", client: str = "", max_messages: int = 80) -> str:
408
+ """Read a full transcript fallback by session id, transcript display name, session_uid, or exact path."""
409
+ return handle_transcript_read(session_ref, transcript_path, client, max_messages)
410
+
411
+
412
+ @mcp.tool
413
+ def nexo_system_catalog(section: str = "", query: str = "", limit: int = 20) -> str:
414
+ """Read NEXO's live system catalog built from core tools, plugins, skills, scripts, crons, projects, and artifacts."""
415
+ return handle_system_catalog(section, query, limit)
416
+
417
+
418
+ @mcp.tool
419
+ def nexo_tool_explain(name: str) -> str:
420
+ """Explain a live NEXO tool/capability from the generated system catalog."""
421
+ return handle_tool_explain(name)
422
+
423
+
383
424
  @mcp.tool
384
425
  def nexo_smart_startup() -> str:
385
426
  """Pre-load relevant cognitive memories based on pending followups, due reminders, and last session topics.