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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/auto_update.py +53 -0
- package/src/claim_graph.py +128 -15
- package/src/cognitive/_trust.py +2 -2
- package/src/compaction_memory.py +227 -0
- package/src/dashboard/app.py +15 -12
- package/src/doctor/providers/runtime.py +140 -11
- package/src/hook_guardrails.py +105 -9
- package/src/hooks/pre-compact.sh +18 -0
- package/src/media_memory.py +303 -0
- package/src/memory_backends.py +71 -0
- package/src/plugins/claims_tools.py +119 -0
- package/src/plugins/cognitive_memory.py +16 -1
- package/src/plugins/media_memory_tools.py +98 -0
- package/src/plugins/memory_export.py +196 -0
- package/src/plugins/user_state_tools.py +43 -0
- package/src/script_registry.py +31 -14
- package/src/scripts/deep-sleep/collect.py +6 -1
- package/src/server.py +1 -0
- package/src/system_catalog.py +383 -16
- package/src/user_state_model.py +170 -0
|
@@ -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
|
+
]
|
package/src/script_registry.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1230
|
-
content = content.replace("
|
|
1231
|
-
content = content.replace("Example
|
|
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":
|
|
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.), "
|