nexo-brain 5.4.1 → 5.4.4
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 +9 -8
- package/package.json +1 -1
- package/src/auto_update.py +1 -0
- package/src/client_sync.py +123 -1
- package/src/doctor/providers/runtime.py +24 -3
- package/src/plugins/episodic_memory.py +85 -15
- package/src/plugins/update.py +1 -0
- package/src/scripts/nexo-postmortem-consolidator.py +61 -29
- package/src/scripts/nexo-reflection.py +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.4",
|
|
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
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `5.4.
|
|
21
|
+
Version `5.4.4` is the current packaged-runtime line: test isolation for tree_hygiene module + fake venv to prevent CI timeout — completes the publish workflow fix from v5.4.3.
|
|
22
22
|
|
|
23
23
|
Previously in `5.4.0`: runtime event bus at `~/.nexo/runtime/events.ndjson`, `nexo notify`, `nexo health --json`, `nexo logs --tail --json`, and a safe flat→nested migration for `calibration.json`.
|
|
24
24
|
|
|
@@ -630,20 +630,21 @@ PreCompact hook saves full checkpoint if conversation is compressed
|
|
|
630
630
|
↓
|
|
631
631
|
PostCompact hook re-injects Core Memory Block → session continues seamlessly
|
|
632
632
|
↓
|
|
633
|
-
Stop hook
|
|
634
|
-
-
|
|
635
|
-
- Session buffer
|
|
636
|
-
- Followups
|
|
637
|
-
-
|
|
633
|
+
Stop hook refreshes the diary draft and approves immediately:
|
|
634
|
+
- Latest changes and decisions stay attached to the active session
|
|
635
|
+
- Session buffer keeps structured tool activity for downstream processing
|
|
636
|
+
- Followups and closing synthesis happen inline when the agent detects real closing intent
|
|
637
|
+
- No mid-conversation blocking from the hook itself
|
|
638
638
|
↓
|
|
639
|
-
|
|
639
|
+
Nocturnal post-mortem consolidator processes the buffer mechanically
|
|
640
640
|
↓
|
|
641
641
|
Nocturnal processes: decay, consolidation, self-audit, dreaming
|
|
642
642
|
```
|
|
643
643
|
|
|
644
644
|
### Reflection Engine
|
|
645
645
|
|
|
646
|
-
|
|
646
|
+
NEXO still ships `nexo-reflection.py` as a standalone analyzer for `session_buffer.jsonl`.
|
|
647
|
+
It is not currently auto-triggered by the stop hook:
|
|
647
648
|
- Extracts recurring tasks, error patterns, mood trends
|
|
648
649
|
- Updates `user_model.json` with observed behavior
|
|
649
650
|
- No LLM required — runs as pure Python
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.4",
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -2115,6 +2115,7 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
|
|
|
2115
2115
|
nexo_home=dest,
|
|
2116
2116
|
runtime_root=dest,
|
|
2117
2117
|
preferences=normalized_preferences,
|
|
2118
|
+
auto_install_missing_claude=True,
|
|
2118
2119
|
)
|
|
2119
2120
|
if client_sync_result.get("ok"):
|
|
2120
2121
|
actions.append("client-sync")
|
package/src/client_sync.py
CHANGED
|
@@ -24,6 +24,7 @@ try:
|
|
|
24
24
|
from client_preferences import (
|
|
25
25
|
BACKEND_NONE,
|
|
26
26
|
INTERACTIVE_CLIENT_KEYS,
|
|
27
|
+
detect_installed_clients,
|
|
27
28
|
normalize_backend_key,
|
|
28
29
|
normalize_client_key,
|
|
29
30
|
normalize_client_preferences,
|
|
@@ -68,6 +69,16 @@ except Exception:
|
|
|
68
69
|
}
|
|
69
70
|
return dict(defaults.get(client, {}))
|
|
70
71
|
|
|
72
|
+
def detect_installed_clients(user_home: str | os.PathLike[str] | None = None) -> dict[str, dict]:
|
|
73
|
+
return {
|
|
74
|
+
"claude_code": {"installed": False, "path": "", "detected_by": "missing"},
|
|
75
|
+
"codex": {"installed": False, "path": "", "detected_by": "missing"},
|
|
76
|
+
"claude_desktop": {"installed": False, "path": "", "detected_by": "missing"},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code"
|
|
81
|
+
|
|
71
82
|
|
|
72
83
|
|
|
73
84
|
def _user_home() -> Path:
|
|
@@ -133,6 +144,108 @@ def _resolve_python(nexo_home: Path, explicit: str = "") -> str:
|
|
|
133
144
|
return explicit or sys.executable
|
|
134
145
|
|
|
135
146
|
|
|
147
|
+
def _cli_install_env(user_home: Path | None = None) -> dict[str, str]:
|
|
148
|
+
env = os.environ.copy()
|
|
149
|
+
if user_home is not None:
|
|
150
|
+
env["HOME"] = str(user_home)
|
|
151
|
+
return env
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _installed_client_path(client_key: str, *, user_home: Path | None = None) -> str:
|
|
155
|
+
info = detect_installed_clients(user_home=user_home).get(client_key, {})
|
|
156
|
+
if info.get("installed"):
|
|
157
|
+
return str(info.get("path") or "")
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = None) -> dict:
|
|
162
|
+
home_path = Path(user_home).expanduser() if user_home else _user_home()
|
|
163
|
+
existing = _installed_client_path("claude_code", user_home=home_path)
|
|
164
|
+
if existing:
|
|
165
|
+
return {
|
|
166
|
+
"ok": True,
|
|
167
|
+
"client": "claude_code",
|
|
168
|
+
"installed": True,
|
|
169
|
+
"changed": False,
|
|
170
|
+
"action": "already_installed",
|
|
171
|
+
"path": existing,
|
|
172
|
+
"attempts": [],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
attempts: list[str] = []
|
|
176
|
+
env = _cli_install_env(home_path)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
probe = subprocess.run(
|
|
180
|
+
["npx", "-y", CLAUDE_CODE_NPM_PACKAGE, "--version"],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
timeout=60,
|
|
184
|
+
env=env,
|
|
185
|
+
)
|
|
186
|
+
if probe.returncode != 0:
|
|
187
|
+
attempts.append((probe.stderr or probe.stdout or "npx probe failed").strip())
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
attempts.append(f"npx probe failed: {exc}")
|
|
190
|
+
|
|
191
|
+
installed_after_probe = _installed_client_path("claude_code", user_home=home_path)
|
|
192
|
+
if installed_after_probe:
|
|
193
|
+
return {
|
|
194
|
+
"ok": True,
|
|
195
|
+
"client": "claude_code",
|
|
196
|
+
"installed": True,
|
|
197
|
+
"changed": True,
|
|
198
|
+
"action": "installed_via_npx",
|
|
199
|
+
"path": installed_after_probe,
|
|
200
|
+
"attempts": attempts,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
install_cmd = (
|
|
204
|
+
["sudo", "npm", "install", "-g", CLAUDE_CODE_NPM_PACKAGE]
|
|
205
|
+
if sys.platform == "linux"
|
|
206
|
+
else ["npm", "install", "-g", CLAUDE_CODE_NPM_PACKAGE]
|
|
207
|
+
)
|
|
208
|
+
install_error = ""
|
|
209
|
+
try:
|
|
210
|
+
install = subprocess.run(
|
|
211
|
+
install_cmd,
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
timeout=180,
|
|
215
|
+
env=env,
|
|
216
|
+
)
|
|
217
|
+
if install.returncode != 0:
|
|
218
|
+
install_error = (install.stderr or install.stdout or "npm install failed").strip()
|
|
219
|
+
attempts.append(install_error)
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
install_error = f"npm install failed: {exc}"
|
|
222
|
+
attempts.append(install_error)
|
|
223
|
+
|
|
224
|
+
installed_path = _installed_client_path("claude_code", user_home=home_path)
|
|
225
|
+
if installed_path:
|
|
226
|
+
return {
|
|
227
|
+
"ok": True,
|
|
228
|
+
"client": "claude_code",
|
|
229
|
+
"installed": True,
|
|
230
|
+
"changed": True,
|
|
231
|
+
"action": "installed",
|
|
232
|
+
"path": installed_path,
|
|
233
|
+
"attempts": attempts,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
error = install_error or "Claude Code install did not produce a `claude` binary in PATH"
|
|
237
|
+
return {
|
|
238
|
+
"ok": False,
|
|
239
|
+
"client": "claude_code",
|
|
240
|
+
"installed": False,
|
|
241
|
+
"changed": False,
|
|
242
|
+
"action": "failed",
|
|
243
|
+
"path": "",
|
|
244
|
+
"attempts": attempts,
|
|
245
|
+
"error": error,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
136
249
|
def build_server_config(
|
|
137
250
|
*,
|
|
138
251
|
nexo_home: str | os.PathLike[str] | None = None,
|
|
@@ -856,6 +969,7 @@ def sync_all_clients(
|
|
|
856
969
|
user_home: str | os.PathLike[str] | None = None,
|
|
857
970
|
enabled_clients: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
858
971
|
preferences: dict | None = None,
|
|
972
|
+
auto_install_missing_claude: bool = False,
|
|
859
973
|
) -> dict:
|
|
860
974
|
if enabled_clients is None:
|
|
861
975
|
if preferences is None:
|
|
@@ -877,6 +991,10 @@ def sync_all_clients(
|
|
|
877
991
|
if not enabled_set:
|
|
878
992
|
enabled_set = {"claude_code"}
|
|
879
993
|
|
|
994
|
+
install_results: dict[str, dict] = {}
|
|
995
|
+
if auto_install_missing_claude and "claude_code" in enabled_set:
|
|
996
|
+
install_results["claude_code"] = ensure_claude_code_installed(user_home=user_home)
|
|
997
|
+
|
|
880
998
|
def _safe(label: str, fn) -> dict:
|
|
881
999
|
if label not in enabled_set:
|
|
882
1000
|
return {
|
|
@@ -902,7 +1020,10 @@ def sync_all_clients(
|
|
|
902
1020
|
"claude_desktop": _safe("claude_desktop", sync_claude_desktop),
|
|
903
1021
|
"codex": _safe("codex", sync_codex),
|
|
904
1022
|
}
|
|
905
|
-
ok =
|
|
1023
|
+
ok = (
|
|
1024
|
+
all(item.get("ok") or item.get("skipped") for item in results.values())
|
|
1025
|
+
and all(item.get("ok") for item in install_results.values())
|
|
1026
|
+
)
|
|
906
1027
|
return {
|
|
907
1028
|
"ok": ok,
|
|
908
1029
|
"nexo_home": str(Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()),
|
|
@@ -911,6 +1032,7 @@ def sync_all_clients(
|
|
|
911
1032
|
runtime_root,
|
|
912
1033
|
)),
|
|
913
1034
|
"enabled_clients": sorted(enabled_set),
|
|
1035
|
+
"install_results": install_results,
|
|
914
1036
|
"clients": results,
|
|
915
1037
|
}
|
|
916
1038
|
|
|
@@ -1773,7 +1773,7 @@ def check_personal_script_registry(fix: bool = False) -> DoctorCheck:
|
|
|
1773
1773
|
)
|
|
1774
1774
|
|
|
1775
1775
|
|
|
1776
|
-
def check_client_backend_preferences() -> DoctorCheck:
|
|
1776
|
+
def check_client_backend_preferences(fix: bool = False) -> DoctorCheck:
|
|
1777
1777
|
schedule = {}
|
|
1778
1778
|
try:
|
|
1779
1779
|
if SCHEDULE_FILE.is_file():
|
|
@@ -1828,7 +1828,28 @@ def check_client_backend_preferences() -> DoctorCheck:
|
|
|
1828
1828
|
repair_plan.append(f"Install {automation_backend} or disable automation in schedule.json")
|
|
1829
1829
|
|
|
1830
1830
|
if not repair_plan and status != "healthy":
|
|
1831
|
-
repair_plan.append(
|
|
1831
|
+
repair_plan.append(
|
|
1832
|
+
"Run `nexo doctor --tier runtime --fix` or `nexo update` so the selected client/backend is installed and re-synced where possible"
|
|
1833
|
+
)
|
|
1834
|
+
|
|
1835
|
+
if fix and status != "healthy":
|
|
1836
|
+
try:
|
|
1837
|
+
from client_sync import sync_all_clients
|
|
1838
|
+
|
|
1839
|
+
sync_all_clients(
|
|
1840
|
+
nexo_home=NEXO_HOME,
|
|
1841
|
+
runtime_root=NEXO_CODE,
|
|
1842
|
+
user_home=Path.home(),
|
|
1843
|
+
preferences=prefs,
|
|
1844
|
+
auto_install_missing_claude=True,
|
|
1845
|
+
)
|
|
1846
|
+
except Exception:
|
|
1847
|
+
pass
|
|
1848
|
+
post = check_client_backend_preferences(fix=False)
|
|
1849
|
+
if post.status == "healthy":
|
|
1850
|
+
post.fixed = True
|
|
1851
|
+
post.summary += " (fixed)"
|
|
1852
|
+
return post
|
|
1832
1853
|
|
|
1833
1854
|
def _profile_label(client_key: str, profile: dict[str, str]) -> str:
|
|
1834
1855
|
bits = [client_key]
|
|
@@ -3151,7 +3172,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
3151
3172
|
safe_check(check_watchdog_status),
|
|
3152
3173
|
safe_check(check_stale_sessions),
|
|
3153
3174
|
safe_check(check_cron_freshness),
|
|
3154
|
-
safe_check(check_client_backend_preferences),
|
|
3175
|
+
safe_check(check_client_backend_preferences, fix=fix),
|
|
3155
3176
|
safe_check(check_client_bootstrap_parity, fix=fix),
|
|
3156
3177
|
safe_check(check_codex_session_parity),
|
|
3157
3178
|
safe_check(check_codex_conditioned_file_discipline),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import datetime
|
|
4
4
|
import json
|
|
5
5
|
import time
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from db import (
|
|
7
8
|
log_decision, update_decision_outcome, search_decisions,
|
|
8
9
|
write_session_diary, read_session_diary,
|
|
@@ -10,6 +11,23 @@ from db import (
|
|
|
10
11
|
recall, get_db, set_linked_outcomes_met,
|
|
11
12
|
)
|
|
12
13
|
|
|
14
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
15
|
+
_REPO_ABS_PREFIX = str(REPO_ROOT) + "/"
|
|
16
|
+
_REPO_IGNORED_TOP_LEVEL = {
|
|
17
|
+
".git",
|
|
18
|
+
".venv",
|
|
19
|
+
".pytest_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
}
|
|
23
|
+
try:
|
|
24
|
+
_REPO_TOP_LEVEL_ENTRIES = {
|
|
25
|
+
path.name for path in REPO_ROOT.iterdir()
|
|
26
|
+
if path.name not in _REPO_IGNORED_TOP_LEVEL
|
|
27
|
+
}
|
|
28
|
+
except OSError:
|
|
29
|
+
_REPO_TOP_LEVEL_ENTRIES = set()
|
|
30
|
+
|
|
13
31
|
|
|
14
32
|
def _cognitive_ingest_safe(content, source_type, source_id="", source_title="", domain=""):
|
|
15
33
|
"""Ingest to cognitive STM. Silently fails if cognitive engine unavailable."""
|
|
@@ -20,6 +38,37 @@ def _cognitive_ingest_safe(content, source_type, source_id="", source_title="",
|
|
|
20
38
|
pass # Cognitive is optional — never block operational writes
|
|
21
39
|
|
|
22
40
|
|
|
41
|
+
def _iter_change_paths(files: str) -> list[str]:
|
|
42
|
+
parts = []
|
|
43
|
+
for chunk in str(files or "").replace("\n", ",").split(","):
|
|
44
|
+
item = chunk.strip()
|
|
45
|
+
if item:
|
|
46
|
+
parts.append(item)
|
|
47
|
+
return parts
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _change_requires_git_commit_ref(files: str) -> bool:
|
|
51
|
+
for item in _iter_change_paths(files):
|
|
52
|
+
normalized = item.replace("\\", "/").strip()
|
|
53
|
+
while normalized.startswith("./"):
|
|
54
|
+
normalized = normalized[2:]
|
|
55
|
+
if normalized.startswith(_REPO_ABS_PREFIX):
|
|
56
|
+
return True
|
|
57
|
+
top_level = normalized.split("/", 1)[0]
|
|
58
|
+
if top_level in _REPO_TOP_LEVEL_ENTRIES:
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_change_count(count: int) -> str:
|
|
64
|
+
return f"{count} cambio" if count == 1 else f"{count} cambios"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _recent_change_phrase(count: int) -> str:
|
|
68
|
+
base = _format_change_count(count)
|
|
69
|
+
return f"{base} reciente" if count == 1 else f"{base} recientes"
|
|
70
|
+
|
|
71
|
+
|
|
23
72
|
def handle_decision_log(domain: str, decision: str, alternatives: str = '',
|
|
24
73
|
based_on: str = '', confidence: str = 'medium',
|
|
25
74
|
context_ref: str = '', session_id: str = '',
|
|
@@ -226,23 +275,35 @@ def handle_session_diary_write(decisions: str, summary: str,
|
|
|
226
275
|
# Episodic memory audit — warn about gaps
|
|
227
276
|
warnings = []
|
|
228
277
|
conn = __import__('db').get_db()
|
|
229
|
-
|
|
230
|
-
"SELECT
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
278
|
+
orphan_change_rows = conn.execute(
|
|
279
|
+
"""SELECT files, created_at FROM change_log
|
|
280
|
+
WHERE (commit_ref IS NULL OR commit_ref = '')"""
|
|
281
|
+
).fetchall()
|
|
282
|
+
repo_orphan_changes = 0
|
|
283
|
+
recent_repo_orphan_changes = 0
|
|
284
|
+
recent_cutoff = conn.execute("SELECT datetime('now', '-7 days')").fetchone()[0]
|
|
285
|
+
for row in orphan_change_rows:
|
|
286
|
+
files = row["files"] if hasattr(row, "keys") else row[0]
|
|
287
|
+
created_at = row["created_at"] if hasattr(row, "keys") else row[1]
|
|
288
|
+
if not _change_requires_git_commit_ref(files):
|
|
289
|
+
continue
|
|
290
|
+
repo_orphan_changes += 1
|
|
291
|
+
if created_at >= recent_cutoff:
|
|
292
|
+
recent_repo_orphan_changes += 1
|
|
293
|
+
if repo_orphan_changes > 0:
|
|
294
|
+
if recent_repo_orphan_changes > 0 and recent_repo_orphan_changes != repo_orphan_changes:
|
|
239
295
|
warnings.append(
|
|
240
|
-
f"{
|
|
296
|
+
f"{_recent_change_phrase(recent_repo_orphan_changes)} de repo sin commit_ref "
|
|
297
|
+
f"({_format_change_count(repo_orphan_changes)} de repo total)"
|
|
298
|
+
)
|
|
299
|
+
elif recent_repo_orphan_changes > 0:
|
|
300
|
+
warnings.append(
|
|
301
|
+
f"{_recent_change_phrase(recent_repo_orphan_changes)} de repo sin commit_ref"
|
|
241
302
|
)
|
|
242
|
-
elif recent_orphan_changes > 0:
|
|
243
|
-
warnings.append(f"{recent_orphan_changes} changes recientes sin commit_ref")
|
|
244
303
|
else:
|
|
245
|
-
warnings.append(
|
|
304
|
+
warnings.append(
|
|
305
|
+
f"{_format_change_count(repo_orphan_changes)} históricos de repo sin commit_ref"
|
|
306
|
+
)
|
|
246
307
|
orphan_decisions = conn.execute(
|
|
247
308
|
"SELECT COUNT(*) FROM decisions WHERE (outcome IS NULL OR outcome = '') AND created_at < datetime('now', '-7 days')"
|
|
248
309
|
).fetchone()[0]
|
|
@@ -342,7 +403,16 @@ def handle_change_log(files: str, what_changed: str, why: str,
|
|
|
342
403
|
pass
|
|
343
404
|
msg = f"Change #{change_id} recorded: {files[:60]} — {what_changed[:60]}"
|
|
344
405
|
if not commit_ref:
|
|
345
|
-
|
|
406
|
+
if _change_requires_git_commit_ref(files):
|
|
407
|
+
msg += (
|
|
408
|
+
f"\n⚠ NO COMMIT. Use nexo_change_commit({change_id}, 'hash') after push."
|
|
409
|
+
)
|
|
410
|
+
else:
|
|
411
|
+
msg += (
|
|
412
|
+
f"\n⚠ NO COMMIT GIT. If this was a local/server-side change, link a marker "
|
|
413
|
+
f"with nexo_change_commit({change_id}, 'server-direct') or "
|
|
414
|
+
f"'local-uncommitted'."
|
|
415
|
+
)
|
|
346
416
|
return msg
|
|
347
417
|
|
|
348
418
|
|
package/src/plugins/update.py
CHANGED
|
@@ -479,6 +479,7 @@ def _sync_packaged_clients() -> tuple[bool, str | None]:
|
|
|
479
479
|
runtime_root=NEXO_HOME,
|
|
480
480
|
operator_name=os.environ.get("NEXO_NAME", ""),
|
|
481
481
|
preferences=preferences,
|
|
482
|
+
auto_install_missing_claude=True,
|
|
482
483
|
)
|
|
483
484
|
except Exception as e:
|
|
484
485
|
return False, f"client sync failed: {e}"
|
|
@@ -87,6 +87,41 @@ def log(msg: str):
|
|
|
87
87
|
f.write(line + "\n")
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _render_sensory_event_content(event: dict) -> str:
|
|
91
|
+
source = event.get("source", "")
|
|
92
|
+
if source == "hook-fallback":
|
|
93
|
+
task_str = " ".join(event.get("tasks", []))
|
|
94
|
+
if len(task_str) < 50 or "," in task_str:
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
parts = []
|
|
98
|
+
tool_name = str(event.get("tool") or "").strip()
|
|
99
|
+
if source == "hook" and tool_name:
|
|
100
|
+
parts.append(f"Tool activity via hook: {tool_name}")
|
|
101
|
+
|
|
102
|
+
for key, label in [("tasks", "Tasks"), ("decisions", "Decisions"),
|
|
103
|
+
("errors_resolved", "Errors"), ("user_patterns", "the user")]:
|
|
104
|
+
val = event.get(key, [])
|
|
105
|
+
if val:
|
|
106
|
+
parts.append(f"{label}: {'; '.join(str(v) for v in val[:3])}")
|
|
107
|
+
|
|
108
|
+
critique = event.get("self_critique", "")
|
|
109
|
+
if critique and "hook-fallback" not in critique:
|
|
110
|
+
parts.append(f"Self-critique: {critique[:200]}")
|
|
111
|
+
|
|
112
|
+
return " | ".join(parts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _rewrite_session_buffer(lines: list[str]) -> None:
|
|
116
|
+
payload = "\n".join(lines)
|
|
117
|
+
if payload:
|
|
118
|
+
payload += "\n"
|
|
119
|
+
|
|
120
|
+
tmp_path = SESSION_BUFFER.with_suffix(SESSION_BUFFER.suffix + ".tmp")
|
|
121
|
+
tmp_path.write_text(payload)
|
|
122
|
+
tmp_path.replace(SESSION_BUFFER)
|
|
123
|
+
|
|
124
|
+
|
|
90
125
|
# ─── Stage 1: Data Collection (Pure Python) ─────────────────────────────────
|
|
91
126
|
|
|
92
127
|
def collect_data() -> dict:
|
|
@@ -257,28 +292,29 @@ def process_sensory_register():
|
|
|
257
292
|
log(" No session_buffer.jsonl found, skipping")
|
|
258
293
|
return
|
|
259
294
|
|
|
260
|
-
|
|
295
|
+
pending_events = []
|
|
296
|
+
retained_lines = []
|
|
261
297
|
try:
|
|
262
298
|
with open(SESSION_BUFFER) as f:
|
|
263
299
|
for line in f:
|
|
264
|
-
|
|
300
|
+
raw_line = line.rstrip("\n")
|
|
301
|
+
line = raw_line.strip()
|
|
265
302
|
if not line:
|
|
266
303
|
continue
|
|
267
304
|
try:
|
|
268
305
|
event = json.loads(line)
|
|
269
|
-
|
|
270
|
-
today_events.append(event)
|
|
306
|
+
pending_events.append((event, line))
|
|
271
307
|
except json.JSONDecodeError:
|
|
272
|
-
|
|
308
|
+
retained_lines.append(raw_line)
|
|
273
309
|
except Exception as e:
|
|
274
310
|
log(f" Error reading session_buffer: {e}")
|
|
275
311
|
return
|
|
276
312
|
|
|
277
|
-
if not
|
|
278
|
-
log(" No events
|
|
313
|
+
if not pending_events:
|
|
314
|
+
log(" No pending events")
|
|
279
315
|
return
|
|
280
316
|
|
|
281
|
-
log(f" Found {len(
|
|
317
|
+
log(f" Found {len(pending_events)} pending events")
|
|
282
318
|
|
|
283
319
|
try:
|
|
284
320
|
import cognitive
|
|
@@ -287,30 +323,16 @@ def process_sensory_register():
|
|
|
287
323
|
return
|
|
288
324
|
|
|
289
325
|
ingested = 0
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if len(task_str) < 50 or "," in task_str:
|
|
295
|
-
continue
|
|
296
|
-
|
|
297
|
-
parts = []
|
|
298
|
-
for key, label in [("tasks", "Tasks"), ("decisions", "Decisions"),
|
|
299
|
-
("errors_resolved", "Errors"), ("user_patterns", "the user")]:
|
|
300
|
-
val = event.get(key, [])
|
|
301
|
-
if val:
|
|
302
|
-
parts.append(f"{label}: {'; '.join(str(v) for v in val[:3])}")
|
|
303
|
-
|
|
304
|
-
critique = event.get("self_critique", "")
|
|
305
|
-
if critique and "hook-fallback" not in critique:
|
|
306
|
-
parts.append(f"Self-critique: {critique[:200]}")
|
|
307
|
-
|
|
308
|
-
content = " | ".join(parts)
|
|
326
|
+
processed_events = 0
|
|
327
|
+
kept_events = 0
|
|
328
|
+
for event, raw_line in pending_events:
|
|
329
|
+
content = _render_sensory_event_content(event)
|
|
309
330
|
if not content or len(content) < 20:
|
|
331
|
+
retained_lines.append(raw_line)
|
|
332
|
+
kept_events += 1
|
|
310
333
|
continue
|
|
311
334
|
|
|
312
335
|
try:
|
|
313
|
-
vec = cognitive.embed(content)
|
|
314
336
|
domain = ""
|
|
315
337
|
lower = content.lower()
|
|
316
338
|
# Add your project keywords for domain detection
|
|
@@ -324,10 +346,20 @@ def process_sensory_register():
|
|
|
324
346
|
domain=domain, created_at=event.get("ts", "")
|
|
325
347
|
)
|
|
326
348
|
ingested += 1
|
|
349
|
+
processed_events += 1
|
|
327
350
|
except Exception as e:
|
|
328
351
|
log(f" Error embedding: {e}")
|
|
352
|
+
retained_lines.append(raw_line)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
_rewrite_session_buffer(retained_lines)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
log(f" Error rewriting session_buffer: {e}")
|
|
329
358
|
|
|
330
|
-
log(
|
|
359
|
+
log(
|
|
360
|
+
f" Ingested {ingested} sensory events into STM "
|
|
361
|
+
f"(processed: {processed_events}, retained: {len(retained_lines)} incl kept={kept_events})"
|
|
362
|
+
)
|
|
331
363
|
|
|
332
364
|
|
|
333
365
|
def analyze_force_events():
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"""
|
|
3
3
|
NEXO Reflection Engine — Processes session_buffer.jsonl entries.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
The runtime still ships this as a standalone analyzer, but it is not
|
|
6
|
+
currently auto-triggered by the stop hook.
|
|
7
7
|
|
|
8
8
|
What it does:
|
|
9
9
|
1. Reads all entries from session_buffer.jsonl
|