nexo-brain 2.6.6 → 2.6.7
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/bin/nexo-brain.js +117 -0
- package/package.json +1 -1
- package/src/auto_close_sessions.py +24 -4
- package/src/auto_update.py +31 -5
- package/src/cli.py +112 -2
- package/src/db/_episodic.py +5 -16
- package/src/evolution_cycle.py +51 -1
- package/src/plugins/episodic_memory.py +1 -1
- package/src/plugins/personal_plugins.py +135 -0
- package/src/plugins/update.py +25 -3
- package/src/public_contribution.py +396 -0
- package/src/scripts/nexo-evolution-run.py +394 -2
- package/templates/plugin-template.py +36 -0
package/src/evolution_cycle.py
CHANGED
|
@@ -57,16 +57,20 @@ def normalize_objective(obj: dict | None) -> dict:
|
|
|
57
57
|
|
|
58
58
|
if "evolution_mode" in source:
|
|
59
59
|
mode = str(source.get("evolution_mode") or "auto").strip().lower()
|
|
60
|
+
if mode in {"public", "public_core", "contributor", "draft_prs"}:
|
|
61
|
+
mode = "public_core"
|
|
60
62
|
else:
|
|
61
63
|
legacy_mode = str(source.get("review_mode") or "").strip().lower()
|
|
62
64
|
if legacy_mode in {"manual", "review"}:
|
|
63
65
|
mode = "review"
|
|
64
66
|
elif legacy_mode in {"managed", "hybrid", "owner", "core"}:
|
|
65
67
|
mode = "managed"
|
|
68
|
+
elif legacy_mode in {"public", "public_core", "contributor", "draft_prs"}:
|
|
69
|
+
mode = "public_core"
|
|
66
70
|
else:
|
|
67
71
|
mode = "auto"
|
|
68
72
|
|
|
69
|
-
if mode not in {"auto", "review", "managed"}:
|
|
73
|
+
if mode not in {"auto", "review", "managed", "public_core"}:
|
|
70
74
|
mode = "auto"
|
|
71
75
|
|
|
72
76
|
dimensions = source.get("dimensions")
|
|
@@ -276,6 +280,10 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
|
|
|
276
280
|
mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
|
|
277
281
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
|
|
278
282
|
immutable_files = "db.py, server.py, plugin_loader.py, storage_router.py, nexo-watchdog.sh, evolution_cycle.py, CLAUDE.md, personality.md, user-profile.md"
|
|
283
|
+
elif mode == "public_core":
|
|
284
|
+
mode_desc = "public core contribution via isolated checkout and Draft PR"
|
|
285
|
+
safe_zones = "isolated public repo checkout only"
|
|
286
|
+
immutable_files = "personal runtime, ~/.nexo/**, local DBs/logs, CLAUDE.md, user-profile.md"
|
|
279
287
|
else:
|
|
280
288
|
mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
|
|
281
289
|
safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/"
|
|
@@ -340,6 +348,48 @@ Max 3 proposals. Quality over quantity. If nothing needs improving, say so."""
|
|
|
340
348
|
return prompt
|
|
341
349
|
|
|
342
350
|
|
|
351
|
+
def build_public_contribution_prompt(*, repo_root: str, cycle_number: int) -> str:
|
|
352
|
+
"""Prompt for the public-core contributor mode.
|
|
353
|
+
|
|
354
|
+
This prompt must never rely on private runtime state. It should inspect only
|
|
355
|
+
the isolated public repo checkout, make one coherent improvement, and end
|
|
356
|
+
by returning machine-readable summary JSON.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
return f"""You are NEXO Public Evolution.
|
|
360
|
+
|
|
361
|
+
You are running inside an isolated checkout of the public NEXO repository.
|
|
362
|
+
Your job is to make one technically coherent improvement to the public core and
|
|
363
|
+
prepare it for a Draft PR.
|
|
364
|
+
|
|
365
|
+
STRICT RULES:
|
|
366
|
+
- Work only inside this repository checkout: {repo_root}
|
|
367
|
+
- You may modify only public core surfaces: src/, bin/, tests/, templates/, hooks/, migrations/, .claude-plugin/
|
|
368
|
+
- Do not read or use ~/.nexo, local DBs, personal scripts, emails, logs, prompts, secrets, or any user-identifying paths
|
|
369
|
+
- Do not push, open PRs, or change git remotes yourself
|
|
370
|
+
- Do not touch README, website, gh-pages, changelog, or release metadata in this mode
|
|
371
|
+
- Focus on one concrete improvement only
|
|
372
|
+
- Run validation for the files you touched
|
|
373
|
+
|
|
374
|
+
What to do:
|
|
375
|
+
1. Inspect the repo and find a real, self-contained improvement in reliability, install/update behavior, cron recovery, diagnostics, hooks, tests, or other core infrastructure.
|
|
376
|
+
2. Implement the change directly in this checkout.
|
|
377
|
+
3. Run the smallest relevant validation commands.
|
|
378
|
+
4. Return ONLY valid JSON with this shape:
|
|
379
|
+
|
|
380
|
+
{{
|
|
381
|
+
"title": "type: short title",
|
|
382
|
+
"problem": "what was wrong",
|
|
383
|
+
"summary": "what you changed",
|
|
384
|
+
"tests": ["command 1", "command 2"],
|
|
385
|
+
"risks": ["risk 1", "risk 2"]
|
|
386
|
+
}}
|
|
387
|
+
|
|
388
|
+
Cycle: #{cycle_number}
|
|
389
|
+
Quality over quantity. One strong improvement is better than three weak ones.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
|
|
343
393
|
def max_auto_changes(total_evolutions: int) -> int:
|
|
344
394
|
"""Progressive trust: 1 for first 4 cycles, 2 for next 4, then 3."""
|
|
345
395
|
if total_evolutions < 4:
|
|
@@ -236,7 +236,7 @@ def handle_session_diary_read(session_id: str = '', last_n: int = 3, last_day: b
|
|
|
236
236
|
Args:
|
|
237
237
|
session_id: Specific session ID to read (optional)
|
|
238
238
|
last_n: Number of recent entries to return (default 3)
|
|
239
|
-
last_day: If true, returns
|
|
239
|
+
last_day: If true, returns the recent continuity window (~36h), including the previous evening. Use this at startup.
|
|
240
240
|
domain: Filter by project context: ecommerce, project-a, nexo, project-b, server, other
|
|
241
241
|
brief: If true, returns ONLY the last diary entry with summary + mental_state + context_next.
|
|
242
242
|
Use this at startup for fast context loading (~1K chars instead of full dump).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""NEXO Personal Plugins — scaffold persistent MCP tools in NEXO_HOME/plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from db import init_db
|
|
10
|
+
from script_registry import create_script
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
14
|
+
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[1])))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _plugins_dir() -> Path:
|
|
18
|
+
path = NEXO_HOME / "plugins"
|
|
19
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _safe_slug(value: str) -> str:
|
|
24
|
+
chars: list[str] = []
|
|
25
|
+
for ch in str(value or "").lower():
|
|
26
|
+
if ch.isalnum():
|
|
27
|
+
chars.append(ch)
|
|
28
|
+
elif ch in {"-", "_", " "}:
|
|
29
|
+
chars.append("-")
|
|
30
|
+
slug = "".join(chars).strip("-")
|
|
31
|
+
return slug or "plugin"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _template_path(name: str) -> Path | None:
|
|
35
|
+
candidates = [
|
|
36
|
+
NEXO_CODE.parent / "templates" / name,
|
|
37
|
+
NEXO_HOME / "templates" / name,
|
|
38
|
+
]
|
|
39
|
+
for candidate in candidates:
|
|
40
|
+
if candidate.is_file():
|
|
41
|
+
return candidate
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_template() -> str:
|
|
46
|
+
template = _template_path("plugin-template.py")
|
|
47
|
+
if template:
|
|
48
|
+
return template.read_text()
|
|
49
|
+
return (
|
|
50
|
+
"from __future__ import annotations\n"
|
|
51
|
+
"import json\n\n"
|
|
52
|
+
"def handle_example_tool(payload_json: str = \"{}\") -> str:\n"
|
|
53
|
+
" try:\n"
|
|
54
|
+
" payload = json.loads(payload_json or \"{}\")\n"
|
|
55
|
+
" except Exception as exc:\n"
|
|
56
|
+
" return json.dumps({\"ok\": False, \"error\": f\"invalid json: {exc}\"}, ensure_ascii=False)\n"
|
|
57
|
+
" return json.dumps({\"ok\": True, \"payload\": payload}, ensure_ascii=False)\n\n"
|
|
58
|
+
"TOOLS = [\n"
|
|
59
|
+
" (handle_example_tool, \"nexo_example_tool\", \"Example personal MCP tool scaffold.\"),\n"
|
|
60
|
+
"]\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _render_plugin_template(*, plugin_stem: str, tool_name: str, description: str) -> str:
|
|
65
|
+
content = _load_template()
|
|
66
|
+
handler_name = f"handle_{plugin_stem.replace('-', '_')}"
|
|
67
|
+
content = content.replace("handle_example_tool", handler_name)
|
|
68
|
+
content = content.replace("nexo_example_tool", tool_name)
|
|
69
|
+
content = content.replace(
|
|
70
|
+
"Personal plugin scaffold created. Edit this handler in NEXO_HOME/plugins.",
|
|
71
|
+
description or f"Personal plugin scaffold for {plugin_stem}.",
|
|
72
|
+
)
|
|
73
|
+
content = content.replace(
|
|
74
|
+
"Example personal MCP tool scaffold. Edit it in NEXO_HOME/plugins.",
|
|
75
|
+
description or f"Personal MCP tool scaffold for {plugin_stem}.",
|
|
76
|
+
)
|
|
77
|
+
return content
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handle_personal_plugin_create(
|
|
81
|
+
name: str,
|
|
82
|
+
description: str = "",
|
|
83
|
+
tool_name: str = "",
|
|
84
|
+
create_companion_script: bool = False,
|
|
85
|
+
script_runtime: str = "python",
|
|
86
|
+
force: bool = False,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Create a personal MCP plugin scaffold in NEXO_HOME/plugins.
|
|
89
|
+
|
|
90
|
+
Optionally also creates a companion script in NEXO_HOME/scripts.
|
|
91
|
+
"""
|
|
92
|
+
init_db()
|
|
93
|
+
plugin_stem = _safe_slug(name)
|
|
94
|
+
filename = f"{plugin_stem}.py"
|
|
95
|
+
tool_name = (tool_name or f"nexo_{plugin_stem.replace('-', '_')}").strip()
|
|
96
|
+
plugin_path = _plugins_dir() / filename
|
|
97
|
+
if plugin_path.exists() and not force:
|
|
98
|
+
return json.dumps({
|
|
99
|
+
"ok": False,
|
|
100
|
+
"error": f"Plugin already exists: {plugin_path}",
|
|
101
|
+
}, ensure_ascii=False)
|
|
102
|
+
|
|
103
|
+
content = _render_plugin_template(
|
|
104
|
+
plugin_stem=plugin_stem,
|
|
105
|
+
tool_name=tool_name,
|
|
106
|
+
description=description or f"Personal MCP tool for {name}.",
|
|
107
|
+
)
|
|
108
|
+
plugin_path.write_text(content)
|
|
109
|
+
|
|
110
|
+
script_result = None
|
|
111
|
+
if create_companion_script:
|
|
112
|
+
script_result = create_script(
|
|
113
|
+
plugin_stem,
|
|
114
|
+
description=f"Companion script for plugin {plugin_stem}",
|
|
115
|
+
runtime=script_runtime,
|
|
116
|
+
force=force,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return json.dumps({
|
|
120
|
+
"ok": True,
|
|
121
|
+
"name": plugin_stem,
|
|
122
|
+
"tool_name": tool_name,
|
|
123
|
+
"plugin_path": str(plugin_path),
|
|
124
|
+
"companion_script": script_result,
|
|
125
|
+
"next_step": f"Load the plugin with nexo_plugin_load(filename='{filename}') after editing it.",
|
|
126
|
+
}, ensure_ascii=False)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
TOOLS = [
|
|
130
|
+
(
|
|
131
|
+
handle_personal_plugin_create,
|
|
132
|
+
"nexo_personal_plugin_create",
|
|
133
|
+
"Create a persistent personal MCP plugin scaffold in NEXO_HOME/plugins, optionally with a companion script in NEXO_HOME/scripts.",
|
|
134
|
+
),
|
|
135
|
+
]
|
package/src/plugins/update.py
CHANGED
|
@@ -341,11 +341,20 @@ def _rollback_npm_package(target_version: str) -> str | None:
|
|
|
341
341
|
return None
|
|
342
342
|
|
|
343
343
|
|
|
344
|
-
def
|
|
344
|
+
def _emit_progress(progress_fn, message: str) -> None:
|
|
345
|
+
if callable(progress_fn):
|
|
346
|
+
try:
|
|
347
|
+
progress_fn(message)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _handle_packaged_update(progress_fn=None) -> str:
|
|
345
353
|
"""Update a packaged (npm) install — no git repo available."""
|
|
346
354
|
old_version = _read_version()
|
|
347
355
|
|
|
348
356
|
# 1. Backup databases BEFORE any changes
|
|
357
|
+
_emit_progress(progress_fn, "Backing up runtime databases...")
|
|
349
358
|
backup_dir, backup_err = _backup_databases()
|
|
350
359
|
if backup_err:
|
|
351
360
|
return f"ABORTED at backup: {backup_err}"
|
|
@@ -353,12 +362,14 @@ def _handle_packaged_update() -> str:
|
|
|
353
362
|
# 2. Backup NEXO_HOME code tree BEFORE npm update
|
|
354
363
|
# postinstall copies hooks/core/plugins/scripts into NEXO_HOME,
|
|
355
364
|
# so we need a full snapshot to restore on failure.
|
|
365
|
+
_emit_progress(progress_fn, "Backing up runtime files...")
|
|
356
366
|
code_backup_dir, code_err = _backup_code_tree()
|
|
357
367
|
if code_err:
|
|
358
368
|
return f"ABORTED at code tree backup: {code_err}"
|
|
359
369
|
|
|
360
370
|
# 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
|
|
361
371
|
try:
|
|
372
|
+
_emit_progress(progress_fn, "Downloading and applying the latest npm package...")
|
|
362
373
|
result = subprocess.run(
|
|
363
374
|
["npm", "update", "-g", "nexo-brain"],
|
|
364
375
|
capture_output=True, text=True, timeout=120,
|
|
@@ -402,16 +413,19 @@ def _handle_packaged_update() -> str:
|
|
|
402
413
|
errors = []
|
|
403
414
|
|
|
404
415
|
# Reinstall pip deps for new version
|
|
416
|
+
_emit_progress(progress_fn, "Reconciling Python dependencies...")
|
|
405
417
|
pip_err = _reinstall_pip_deps()
|
|
406
418
|
if pip_err:
|
|
407
419
|
errors.append(f"pip deps: {pip_err}")
|
|
408
420
|
|
|
409
421
|
# Run migrations
|
|
422
|
+
_emit_progress(progress_fn, "Running runtime migrations...")
|
|
410
423
|
mig_err = _run_migrations()
|
|
411
424
|
if mig_err:
|
|
412
425
|
errors.append(f"migrations: {mig_err}")
|
|
413
426
|
|
|
414
427
|
# Verify server can still import
|
|
428
|
+
_emit_progress(progress_fn, "Verifying runtime import health...")
|
|
415
429
|
verify_err = _verify_import()
|
|
416
430
|
if verify_err:
|
|
417
431
|
errors.append(f"verification: {verify_err}")
|
|
@@ -457,7 +471,7 @@ def _handle_packaged_update() -> str:
|
|
|
457
471
|
return "\n".join(lines)
|
|
458
472
|
|
|
459
473
|
|
|
460
|
-
def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
474
|
+
def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None) -> str:
|
|
461
475
|
"""Pull latest NEXO code, backup databases, run migrations, and verify.
|
|
462
476
|
|
|
463
477
|
Supports both git checkouts and packaged (npm) installs.
|
|
@@ -477,7 +491,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
477
491
|
"""
|
|
478
492
|
# Packaged install — no git repo
|
|
479
493
|
if not _is_git_repo():
|
|
480
|
-
return _handle_packaged_update()
|
|
494
|
+
return _handle_packaged_update(progress_fn=progress_fn)
|
|
481
495
|
|
|
482
496
|
steps_done = []
|
|
483
497
|
old_commit = None
|
|
@@ -485,6 +499,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
485
499
|
|
|
486
500
|
try:
|
|
487
501
|
# Step 1: Check dirty (full worktree)
|
|
502
|
+
_emit_progress(progress_fn, "Checking repository state...")
|
|
488
503
|
dirty_err = _check_dirty()
|
|
489
504
|
if dirty_err:
|
|
490
505
|
return f"ABORTED: {dirty_err}"
|
|
@@ -498,12 +513,14 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
498
513
|
return "ABORTED: Not a git repository or git not available."
|
|
499
514
|
|
|
500
515
|
# Step 2: Backup databases
|
|
516
|
+
_emit_progress(progress_fn, "Backing up runtime databases...")
|
|
501
517
|
backup_dir, backup_err = _backup_databases()
|
|
502
518
|
if backup_err:
|
|
503
519
|
return f"ABORTED at backup: {backup_err}"
|
|
504
520
|
steps_done.append("backup")
|
|
505
521
|
|
|
506
522
|
# Step 3: git pull
|
|
523
|
+
_emit_progress(progress_fn, "Pulling latest source changes...")
|
|
507
524
|
rc, pull_out, pull_err = _git("pull", remote, branch)
|
|
508
525
|
if rc != 0:
|
|
509
526
|
return f"ABORTED at git pull: {pull_err or pull_out}"
|
|
@@ -517,6 +534,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
517
534
|
|
|
518
535
|
# Step 5: Reinstall pip dependencies if requirements.txt changed
|
|
519
536
|
if deps_changed or version_changed:
|
|
537
|
+
_emit_progress(progress_fn, "Reconciling Python dependencies...")
|
|
520
538
|
pip_err = _reinstall_pip_deps()
|
|
521
539
|
if pip_err:
|
|
522
540
|
raise RuntimeError(f"Pip install failed: {pip_err}")
|
|
@@ -524,12 +542,14 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
524
542
|
|
|
525
543
|
# Step 6: Run migrations if version changed
|
|
526
544
|
if version_changed:
|
|
545
|
+
_emit_progress(progress_fn, "Running runtime migrations...")
|
|
527
546
|
mig_err = _run_migrations()
|
|
528
547
|
if mig_err:
|
|
529
548
|
raise RuntimeError(f"Migration failed: {mig_err}")
|
|
530
549
|
steps_done.append("migrations")
|
|
531
550
|
|
|
532
551
|
# Step 7: Verify import
|
|
552
|
+
_emit_progress(progress_fn, "Verifying runtime import health...")
|
|
533
553
|
verify_err = _verify_import()
|
|
534
554
|
if verify_err:
|
|
535
555
|
raise RuntimeError(f"Verification failed: {verify_err}")
|
|
@@ -540,6 +560,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
540
560
|
try:
|
|
541
561
|
cron_sync_path = SRC_DIR / "crons" / "sync.py"
|
|
542
562
|
if cron_sync_path.exists():
|
|
563
|
+
_emit_progress(progress_fn, "Syncing core cron definitions...")
|
|
543
564
|
r = subprocess.run(
|
|
544
565
|
[sys.executable, str(cron_sync_path)],
|
|
545
566
|
capture_output=True, text=True, timeout=30,
|
|
@@ -557,6 +578,7 @@ def handle_update(remote: str = "origin", branch: str = "main") -> str:
|
|
|
557
578
|
|
|
558
579
|
# Step 9: Sync hooks to NEXO_HOME
|
|
559
580
|
try:
|
|
581
|
+
_emit_progress(progress_fn, "Syncing core Claude hooks...")
|
|
560
582
|
_sync_hooks_to_home()
|
|
561
583
|
steps_done.append("hook-sync")
|
|
562
584
|
except Exception as e:
|