nexo-brain 3.2.0 → 4.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.
@@ -0,0 +1,196 @@
1
+ """Readable export and auto-flush inspection tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import cognitive
11
+ import claim_graph
12
+ import compaction_memory
13
+ import media_memory
14
+ import user_state_model
15
+ from db import get_db
16
+ from memory_backends import get_backend, list_backends
17
+
18
+
19
+ def _nexo_home() -> Path:
20
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
21
+
22
+
23
+ def _write(path: Path, text: str) -> None:
24
+ path.parent.mkdir(parents=True, exist_ok=True)
25
+ path.write_text(text, encoding="utf-8")
26
+
27
+
28
+ def handle_auto_flush_recent(limit: int = 20, session_id: str = "") -> str:
29
+ rows = compaction_memory.list_auto_flushes(session_id=session_id, limit=limit)
30
+ if not rows:
31
+ return "No auto-flush records."
32
+ lines = [f"AUTO-FLUSH — {len(rows)} record(s):", ""]
33
+ for row in rows:
34
+ lines.append(f" #{row['id']} {row['created_at']} [{row.get('session_id','unknown')}]")
35
+ lines.append(f" {row.get('summary','')[:220]}")
36
+ if row.get("next_step"):
37
+ lines.append(f" next: {row['next_step'][:160]}")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def handle_auto_flush_stats(days: int = 7) -> str:
42
+ stats = compaction_memory.auto_flush_stats(days=days)
43
+ return (
44
+ f"AUTO-FLUSH STATS — {stats['window_days']}d\n"
45
+ f" total: {stats['total']}\n"
46
+ f" backend: {stats['backend']}\n"
47
+ f" by_source: {stats['by_source']}"
48
+ )
49
+
50
+
51
+ def handle_memory_backend_status() -> str:
52
+ active = get_backend()
53
+ backends = list_backends()
54
+ lines = [
55
+ f"MEMORY BACKEND: {active.key} — {active.label}",
56
+ f"Description: {active.description}",
57
+ f"Supports: {', '.join(active.supports)}",
58
+ "",
59
+ "Registered backends:",
60
+ ]
61
+ for item in backends:
62
+ marker = "*" if item["active"] else "-"
63
+ lines.append(f" {marker} {item['key']} [{item['maturity']}] {item['label']}")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def handle_memory_export(format: str = "markdown", output_dir: str = "") -> str:
68
+ if format.strip().lower() != "markdown":
69
+ return "ERROR: only markdown export is supported for now."
70
+
71
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
72
+ root = Path(output_dir).expanduser() if output_dir.strip() else (_nexo_home() / "exports" / "memory" / stamp)
73
+ root.mkdir(parents=True, exist_ok=True)
74
+
75
+ conn = get_db()
76
+ learnings = [dict(r) for r in conn.execute("SELECT id, category, title, status, prevention, updated_at FROM learnings ORDER BY updated_at DESC LIMIT 50").fetchall()]
77
+ decisions = [dict(r) for r in conn.execute("SELECT id, domain, decision, confidence, status, created_at FROM decisions ORDER BY created_at DESC LIMIT 50").fetchall()]
78
+ claims = claim_graph.search_claims(limit=100)
79
+ claim_lint = claim_graph.lint_claims(limit=50)
80
+ media = media_memory.list_media_memories(limit=100)
81
+ flushes = compaction_memory.list_auto_flushes(limit=100)
82
+ user_state = user_state_model.build_user_state(days=7, persist=False)
83
+ user_history = user_state_model.list_user_state_snapshots(limit=30)
84
+ cognitive_stats = cognitive.get_stats()
85
+
86
+ _write(
87
+ root / "README.md",
88
+ "\n".join(
89
+ [
90
+ "# NEXO Memory Export",
91
+ "",
92
+ f"- Generated: {datetime.now().isoformat(timespec='seconds')}",
93
+ f"- Backend: {get_backend().key}",
94
+ f"- Learnings: {len(learnings)}",
95
+ f"- Decisions: {len(decisions)}",
96
+ f"- Claims: {len(claims)}",
97
+ f"- Media memories: {len(media)}",
98
+ f"- Auto-flush records: {len(flushes)}",
99
+ "",
100
+ "Files:",
101
+ "- `learnings.md`",
102
+ "- `decisions.md`",
103
+ "- `claims.md`",
104
+ "- `media.md`",
105
+ "- `auto-flush.md`",
106
+ "- `user-state.md`",
107
+ "- `cognitive.json`",
108
+ ]
109
+ ),
110
+ )
111
+ _write(
112
+ root / "learnings.md",
113
+ "\n".join(
114
+ ["# Learnings", ""]
115
+ + [
116
+ f"- #{item['id']} [{item.get('category','general')}] {item['title']} "
117
+ f"({item.get('status','active')}, updated {item.get('updated_at','')})"
118
+ for item in learnings
119
+ ]
120
+ ),
121
+ )
122
+ _write(
123
+ root / "decisions.md",
124
+ "\n".join(
125
+ ["# Decisions", ""]
126
+ + [
127
+ f"- #{item['id']} [{item.get('domain','other')}] {item['decision']} "
128
+ f"({item.get('confidence','medium')}, {item.get('status','pending_review')})"
129
+ for item in decisions
130
+ ]
131
+ ),
132
+ )
133
+ _write(
134
+ root / "claims.md",
135
+ "\n".join(
136
+ ["# Claims", ""]
137
+ + [
138
+ f"- #{item['id']} [{item.get('verification_status','unverified')}] "
139
+ f"{item.get('freshness_state','?')}({item.get('freshness_score',0)}): {item['text']}"
140
+ for item in claims
141
+ ]
142
+ + ["", "## Attention", ""]
143
+ + [
144
+ f"- #{item['id']} [{', '.join(item.get('lint_reasons', []))}] {item['text']}"
145
+ for item in claim_lint
146
+ ]
147
+ ),
148
+ )
149
+ _write(
150
+ root / "media.md",
151
+ "\n".join(
152
+ ["# Media Memory", ""]
153
+ + [
154
+ f"- #{item['id']} [{item['media_type']}] {item['title']} :: {item.get('file_path') or item.get('url') or 'n/a'}"
155
+ for item in media
156
+ ]
157
+ ),
158
+ )
159
+ _write(
160
+ root / "auto-flush.md",
161
+ "\n".join(
162
+ ["# Auto Flush", ""]
163
+ + [
164
+ f"- #{item['id']} [{item.get('session_id','unknown')}] {item.get('created_at','')}: "
165
+ f"{item.get('summary','')}"
166
+ for item in flushes
167
+ ]
168
+ ),
169
+ )
170
+ _write(
171
+ root / "user-state.md",
172
+ "\n".join(
173
+ [
174
+ "# User State",
175
+ "",
176
+ f"- Current: {user_state['state_label']} ({user_state['confidence']})",
177
+ f"- Trust: {user_state['trust_score']}",
178
+ f"- Guidance: {user_state['guidance']}",
179
+ "",
180
+ "## Signals",
181
+ ]
182
+ + [f"- {key}: {value}" for key, value in user_state["signals"].items()]
183
+ + ["", "## History", ""]
184
+ + [f"- {item['created_at']} :: {item['state_label']} ({item['confidence']})" for item in user_history]
185
+ ),
186
+ )
187
+ _write(root / "cognitive.json", json.dumps(cognitive_stats, indent=2, sort_keys=True))
188
+ return f"Memory export written to {root}"
189
+
190
+
191
+ TOOLS = [
192
+ (handle_auto_flush_recent, "nexo_auto_flush_recent", "Show recent structured auto-flush records written before compaction."),
193
+ (handle_auto_flush_stats, "nexo_auto_flush_stats", "Stats for pre-compaction auto-flush activity."),
194
+ (handle_memory_backend_status, "nexo_memory_backend_status", "Show the active memory backend contract and registered backend list."),
195
+ (handle_memory_export, "nexo_memory_export", "Export a readable markdown snapshot of key NEXO memory layers."),
196
+ ]
@@ -0,0 +1,43 @@
1
+ """User-state modeling tools."""
2
+
3
+ import user_state_model
4
+
5
+
6
+ def handle_user_state(days: int = 7, persist: bool = True) -> str:
7
+ snapshot = user_state_model.build_user_state(days=days, persist=persist)
8
+ lines = [
9
+ f"USER STATE: {snapshot['state_label'].upper()} (confidence={snapshot['confidence']})",
10
+ f"Trust: {snapshot['trust_score']}/100",
11
+ f"Guidance: {snapshot['guidance']}",
12
+ "Signals:",
13
+ ]
14
+ for key, value in snapshot["signals"].items():
15
+ lines.append(f" {key}: {value}")
16
+ return "\n".join(lines)
17
+
18
+
19
+ def handle_user_state_history(limit: int = 20) -> str:
20
+ items = user_state_model.list_user_state_snapshots(limit=limit)
21
+ if not items:
22
+ return "No user-state snapshots yet."
23
+ lines = [f"USER STATE HISTORY — {len(items)} snapshot(s):", ""]
24
+ for item in items:
25
+ lines.append(f" {item['created_at']} — {item['state_label']} ({item['confidence']})")
26
+ return "\n".join(lines)
27
+
28
+
29
+ def handle_user_state_stats(days: int = 30) -> str:
30
+ stats = user_state_model.user_state_stats(days=days)
31
+ return (
32
+ f"USER STATE STATS — {days}d\n"
33
+ f" snapshots: {stats['snapshots']}\n"
34
+ f" backend: {stats['backend']}\n"
35
+ f" by_state: {stats['by_state']}"
36
+ )
37
+
38
+
39
+ TOOLS = [
40
+ (handle_user_state, "nexo_user_state", "Compute a richer, inspectable user-state snapshot from trust, corrections, sentiment, and hot context."),
41
+ (handle_user_state_history, "nexo_user_state_history", "List recent user-state snapshots."),
42
+ (handle_user_state_stats, "nexo_user_state_stats", "Aggregate user-state snapshots by label."),
43
+ ]
@@ -77,6 +77,7 @@ METADATA_KEYS = {
77
77
  SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
78
78
  PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
79
79
  SUPPORTED_RECOVERY_POLICIES = {"none", "run_once_on_wake", "catchup", "restart", "restart_daemon"}
80
+ PERSONAL_SCRIPT_FILENAME_PREFIX = "ps-"
80
81
 
81
82
 
82
83
  def get_nexo_home() -> Path:
@@ -248,6 +249,17 @@ def _safe_slug(value: str) -> str:
248
249
  return slug or "script"
249
250
 
250
251
 
252
+ def has_personal_script_filename_prefix(value: str) -> bool:
253
+ return _safe_slug(value).startswith(PERSONAL_SCRIPT_FILENAME_PREFIX)
254
+
255
+
256
+ def _logical_personal_script_name(name: str) -> str:
257
+ slug = _safe_slug(name)
258
+ if slug.startswith(PERSONAL_SCRIPT_FILENAME_PREFIX):
259
+ slug = slug[len(PERSONAL_SCRIPT_FILENAME_PREFIX):]
260
+ return slug or "personal-script"
261
+
262
+
251
263
  def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
252
264
  """Parse desired schedule metadata from inline script metadata."""
253
265
  explicit_name = metadata.get("name", "").strip()
@@ -460,7 +472,7 @@ def get_declared_schedule(metadata: dict, default_name: str = "") -> dict:
460
472
  def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str, reason: str = "") -> dict:
461
473
  runtime = classify_runtime(path, meta)
462
474
  name = meta.get("name", path.stem)
463
- return {
475
+ entry = {
464
476
  "name": name,
465
477
  "runtime": runtime,
466
478
  "description": meta.get("description", ""),
@@ -470,7 +482,11 @@ def _script_entry(path: Path, meta: dict, *, is_core: bool, classification: str,
470
482
  "classification": classification,
471
483
  "reason": reason,
472
484
  "declared_schedule": get_declared_schedule(meta, name),
485
+ "filename_prefixed": has_personal_script_filename_prefix(path.stem),
473
486
  }
487
+ if classification == "personal":
488
+ entry["naming_policy"] = "preferred" if entry["filename_prefixed"] else "legacy-nonprefixed"
489
+ return entry
474
490
 
475
491
 
476
492
  def classify_scripts_dir() -> dict:
@@ -1173,13 +1189,7 @@ def _template_path(filename: str) -> Path | None:
1173
1189
 
1174
1190
 
1175
1191
  def _script_filename_from_name(name: str, runtime: str) -> str:
1176
- slug = []
1177
- for ch in name.strip().lower():
1178
- if ch.isalnum():
1179
- slug.append(ch)
1180
- elif ch in {" ", "-", "_"}:
1181
- slug.append("-")
1182
- stem = "".join(slug).strip("-") or "personal-script"
1192
+ stem = _safe_slug(name) or "personal-script"
1183
1193
  ext = {
1184
1194
  "python": ".py",
1185
1195
  "shell": ".sh",
@@ -1189,6 +1199,11 @@ def _script_filename_from_name(name: str, runtime: str) -> str:
1189
1199
  return stem + ext
1190
1200
 
1191
1201
 
1202
+ def _personal_script_filename_from_name(name: str, runtime: str) -> str:
1203
+ logical_name = _logical_personal_script_name(name)
1204
+ return _script_filename_from_name(f"{PERSONAL_SCRIPT_FILENAME_PREFIX}{logical_name}", runtime)
1205
+
1206
+
1192
1207
  def create_script(name: str, *, description: str = "", runtime: str = "python", force: bool = False) -> dict:
1193
1208
  runtime = runtime if runtime in SUPPORTED_RUNTIMES else "python"
1194
1209
  if runtime == "unknown":
@@ -1196,7 +1211,8 @@ def create_script(name: str, *, description: str = "", runtime: str = "python",
1196
1211
 
1197
1212
  scripts_dir = get_scripts_dir()
1198
1213
  scripts_dir.mkdir(parents=True, exist_ok=True)
1199
- filename = _script_filename_from_name(name, runtime)
1214
+ logical_name = _logical_personal_script_name(name)
1215
+ filename = _personal_script_filename_from_name(name, runtime)
1200
1216
  path = scripts_dir / filename
1201
1217
  if path.exists() and not force:
1202
1218
  raise FileExistsError(f"Script already exists: {path}")
@@ -1226,10 +1242,9 @@ def create_script(name: str, *, description: str = "", runtime: str = "python",
1226
1242
  "print('hello')\n"
1227
1243
  )
1228
1244
 
1229
- script_name = Path(filename).stem
1230
- content = content.replace("example-script", script_name)
1231
- content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {script_name}")
1232
- content = content.replace("Example shell script using NEXO", description or f"Personal script: {script_name}")
1245
+ content = content.replace("example-script", logical_name)
1246
+ content = content.replace("Example personal script using the stable NEXO CLI", description or f"Personal script: {logical_name}")
1247
+ content = content.replace("Example shell script using NEXO", description or f"Personal script: {logical_name}")
1233
1248
 
1234
1249
  path.write_text(content)
1235
1250
  if runtime in {"shell", "python"}:
@@ -1237,8 +1252,10 @@ def create_script(name: str, *, description: str = "", runtime: str = "python",
1237
1252
  sync_result = sync_personal_scripts()
1238
1253
  return {
1239
1254
  "ok": True,
1240
- "name": script_name,
1255
+ "name": logical_name,
1256
+ "requested_name": name,
1241
1257
  "path": str(path),
1258
+ "filename": filename,
1242
1259
  "runtime": runtime,
1243
1260
  "description": description,
1244
1261
  "sync": sync_result,
@@ -19,10 +19,15 @@ import sys
19
19
  from collections import Counter
20
20
  from datetime import datetime, timedelta
21
21
  from pathlib import Path
22
+
23
+ _DEFAULT_RUNTIME_ROOT = Path(__file__).resolve().parents[2]
24
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
25
+ if str(NEXO_CODE) not in sys.path:
26
+ sys.path.insert(0, str(NEXO_CODE))
27
+
22
28
  import transcript_utils as _transcripts
23
29
 
24
30
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
25
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", ""))
26
31
  DEEP_SLEEP_DIR = NEXO_HOME / "operations" / "deep-sleep"
27
32
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
28
33
  COGNITIVE_DB = NEXO_HOME / "data" / "cognitive.db"
package/src/server.py CHANGED
@@ -220,6 +220,7 @@ mcp = FastMCP(
220
220
  "`nexo_recent_context_capture(...)` / `nexo_recent_context_resolve(...)` for important ongoing threads. "
221
221
  "If that is not enough, use `nexo_transcript_search(...)` / `nexo_transcript_read(...)` as the raw fallback to full conversations. "
222
222
  "Use `nexo_system_catalog(...)` / `nexo_tool_explain(...)` when you need the live map of NEXO itself. "
223
+ "Before the first use of an unfamiliar NEXO tool, call `nexo_tool_explain(name)` to see its signature, examples, workflow notes, and common errors. "
223
224
  "Capture: errors→`nexo_learning_add`, prefs, entities, decisions\n"
224
225
  "- **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"
225
226
  "- **Diary:** When user signals end of session (any language, any style — 'bye', 'done', 'cierro', etc.), "