nexo-brain 7.1.2 → 7.1.3
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 +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +11 -1
- package/src/cli.py +30 -0
- package/src/enforcement_engine.py +6 -9
- package/src/plugins/update.py +34 -14
- package/src/scripts/deep-sleep/extract.py +3 -6
- package/src/t4_llm_gate.py +17 -84
- package/src/user_data_portability.py +110 -21
- package/templates/core-prompts/deep-sleep-extract-json-conversion.md +6 -0
- package/templates/core-prompts/r13-pre-edit-guard-injection.md +1 -0
- package/templates/core-prompts/t4-r15-project-context-gate.md +14 -0
- package/templates/core-prompts/t4-r23e-force-push-gate.md +14 -0
- package/templates/core-prompts/t4-r23f-db-no-where-gate.md +14 -0
- package/templates/core-prompts/t4-r23h-shebang-mismatch-gate.md +14 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.3",
|
|
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 `7.1.
|
|
21
|
+
Version `7.1.3` is the current packaged-runtime line. It closes the remaining post-`7.1.2` product/runtime gap for packaged Desktop-managed installs: the Brain updater can reuse Desktop's bundled npm runtime, portable user-data restores are inspectable before import, product-mode detection is tighter on real packaged machines, and the public release surfaces once again match the runtime line that actually ships. The companion NEXO Desktop client (v0.22.3, closed-source distributed separately) embeds the same release line for its guided bootstrap, repair, and restore flow.
|
|
22
22
|
|
|
23
23
|
Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.3",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain \u2014 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
|
@@ -20,7 +20,17 @@ import threading
|
|
|
20
20
|
import time
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
try:
|
|
24
|
+
from product_mode import enforce_desktop_product_contract
|
|
25
|
+
except ModuleNotFoundError as exc:
|
|
26
|
+
if getattr(exc, "name", "") != "product_mode":
|
|
27
|
+
raise
|
|
28
|
+
_core_runtime = Path(__file__).resolve().parent / "core"
|
|
29
|
+
if _core_runtime.is_dir():
|
|
30
|
+
core_path = str(_core_runtime)
|
|
31
|
+
if core_path not in sys.path:
|
|
32
|
+
sys.path.insert(0, core_path)
|
|
33
|
+
from product_mode import enforce_desktop_product_contract
|
|
24
34
|
from runtime_home import export_resolved_nexo_home, managed_nexo_home
|
|
25
35
|
|
|
26
36
|
try:
|
package/src/cli.py
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
Entry points:
|
|
5
5
|
nexo chat [PATH]
|
|
6
6
|
nexo export [PATH] [--json]
|
|
7
|
+
nexo import-inspect PATH [--json]
|
|
7
8
|
nexo import PATH [--json]
|
|
8
9
|
nexo scripts list [--all] [--json]
|
|
9
10
|
nexo scripts create NAME [--runtime python|shell] [--description TEXT]
|
|
@@ -857,6 +858,27 @@ def _import_bundle(args):
|
|
|
857
858
|
return 0 if result.get("ok") else 1
|
|
858
859
|
|
|
859
860
|
|
|
861
|
+
def _inspect_bundle(args):
|
|
862
|
+
from user_data_portability import inspect_user_bundle
|
|
863
|
+
|
|
864
|
+
result = inspect_user_bundle(args.path)
|
|
865
|
+
if args.json:
|
|
866
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
867
|
+
else:
|
|
868
|
+
if not result.get("ok"):
|
|
869
|
+
print(result.get("error", "Inspect failed"), file=sys.stderr)
|
|
870
|
+
return 1
|
|
871
|
+
sections = result.get("section_names", [])
|
|
872
|
+
print(f"Bundle ready: {result['path']}")
|
|
873
|
+
print(f" Bundle version: {result.get('bundle_version', '?')}")
|
|
874
|
+
print(f" Current runtime: {result.get('current_version', '?')}")
|
|
875
|
+
print(f" Sections: {', '.join(sections) if sections else '(none)'}")
|
|
876
|
+
warnings = result.get("warning_codes", [])
|
|
877
|
+
if warnings:
|
|
878
|
+
print(f" Warnings: {', '.join(warnings)}")
|
|
879
|
+
return 0 if result.get("ok") else 1
|
|
880
|
+
|
|
881
|
+
|
|
860
882
|
def _runtime_python_candidates() -> list[str]:
|
|
861
883
|
candidates: list[str] = []
|
|
862
884
|
seen: set[str] = set()
|
|
@@ -2321,6 +2343,7 @@ def _print_help():
|
|
|
2321
2343
|
Commands:
|
|
2322
2344
|
nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
|
|
2323
2345
|
nexo export [path] Export a portable user-data bundle
|
|
2346
|
+
nexo import-inspect PATH Inspect a portable user-data bundle
|
|
2324
2347
|
nexo import PATH Import a portable user-data bundle
|
|
2325
2348
|
nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
|
|
2326
2349
|
nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|schedule|run|doctor|call|unschedule|remove
|
|
@@ -2364,6 +2387,11 @@ def main():
|
|
|
2364
2387
|
export_parser.add_argument("path", nargs="?", default="", help="Output bundle path (default: NEXO_HOME/exports/...)")
|
|
2365
2388
|
export_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2366
2389
|
|
|
2390
|
+
# -- import inspect --
|
|
2391
|
+
inspect_parser = sub.add_parser("import-inspect", help="Inspect a portable user-data bundle")
|
|
2392
|
+
inspect_parser.add_argument("path", help="Bundle path created by `nexo export`")
|
|
2393
|
+
inspect_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2394
|
+
|
|
2367
2395
|
# -- import --
|
|
2368
2396
|
import_parser = sub.add_parser("import", help="Import a portable user-data bundle")
|
|
2369
2397
|
import_parser.add_argument("path", help="Bundle path created by `nexo export`")
|
|
@@ -2822,6 +2850,8 @@ def main():
|
|
|
2822
2850
|
return _chat(args)
|
|
2823
2851
|
elif args.command == "export":
|
|
2824
2852
|
return _export_bundle(args)
|
|
2853
|
+
elif args.command == "import-inspect":
|
|
2854
|
+
return _inspect_bundle(args)
|
|
2825
2855
|
elif args.command == "import":
|
|
2826
2856
|
return _import_bundle(args)
|
|
2827
2857
|
elif args.command == "update":
|
|
@@ -16,6 +16,7 @@ import time
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
import re
|
|
18
18
|
import paths
|
|
19
|
+
from core_prompts import render_core_prompt
|
|
19
20
|
|
|
20
21
|
try:
|
|
21
22
|
from r13_pre_edit_guard import should_inject_r13, ToolCallRecord, WATCHED_WRITE_TOOLS
|
|
@@ -524,15 +525,11 @@ class HeadlessEnforcer:
|
|
|
524
525
|
if tag is None:
|
|
525
526
|
return
|
|
526
527
|
path_str = ", ".join(files) if files else "(unknown target)"
|
|
527
|
-
prompt = (
|
|
528
|
-
"
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
"nexo_task_acknowledge_guard before the next edit. Do not produce "
|
|
533
|
-
"visible text for this reminder — act on it. After handling the silent "
|
|
534
|
-
"action, continue with your previous task; this reminder is silent "
|
|
535
|
-
"context, not a new instruction."
|
|
528
|
+
prompt = render_core_prompt(
|
|
529
|
+
"r13-pre-edit-guard-injection",
|
|
530
|
+
tool_name=tool_name,
|
|
531
|
+
path_str=path_str,
|
|
532
|
+
first_file=(files[0] if files else ""),
|
|
536
533
|
)
|
|
537
534
|
if mode == "shadow":
|
|
538
535
|
_logger.info("[R13 SHADOW] would inject: tag=%s files=%s", tag, files)
|
package/src/plugins/update.py
CHANGED
|
@@ -129,8 +129,8 @@ def _ensure_managed_venv(runtime_root: Path = NEXO_HOME) -> str | None:
|
|
|
129
129
|
def _find_npm_pkg_src() -> Path | None:
|
|
130
130
|
"""Locate the nexo-brain npm package's src/ directory for requirements.txt."""
|
|
131
131
|
try:
|
|
132
|
-
result =
|
|
133
|
-
["
|
|
132
|
+
result = _run_npm(
|
|
133
|
+
["root", "-g"],
|
|
134
134
|
capture_output=True, text=True, timeout=10,
|
|
135
135
|
)
|
|
136
136
|
if result.returncode == 0:
|
|
@@ -142,6 +142,26 @@ def _find_npm_pkg_src() -> Path | None:
|
|
|
142
142
|
return None
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _npm_command_parts() -> tuple[list[str], dict[str, str]]:
|
|
146
|
+
"""Return the npm invocation, preferring Desktop's managed runtime when present."""
|
|
147
|
+
desktop_node = str(os.environ.get("NEXO_DESKTOP_NODE", "")).strip()
|
|
148
|
+
bundled_npm_cli = str(os.environ.get("NEXO_DESKTOP_NPM_CLI", "")).strip()
|
|
149
|
+
env = dict(os.environ)
|
|
150
|
+
if desktop_node and bundled_npm_cli and Path(desktop_node).exists():
|
|
151
|
+
env["ELECTRON_RUN_AS_NODE"] = "1"
|
|
152
|
+
return [desktop_node, bundled_npm_cli], env
|
|
153
|
+
return ["npm"], env
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _run_npm(args: list[str], **kwargs):
|
|
157
|
+
cmd, env = _npm_command_parts()
|
|
158
|
+
extra_env = kwargs.pop("env", None)
|
|
159
|
+
merged_env = dict(env)
|
|
160
|
+
if extra_env:
|
|
161
|
+
merged_env.update(extra_env)
|
|
162
|
+
return subprocess.run([*cmd, *args], env=merged_env, **kwargs)
|
|
163
|
+
|
|
164
|
+
|
|
145
165
|
def _runtime_code_root(runtime_root: Path | None = None) -> Path:
|
|
146
166
|
runtime_root = Path(runtime_root or NEXO_HOME)
|
|
147
167
|
core_root = runtime_root / "core"
|
|
@@ -497,8 +517,8 @@ def _validate_npm_name(name: str) -> bool:
|
|
|
497
517
|
def _get_npm_global_version(package_name: str) -> str | None:
|
|
498
518
|
"""Return the currently installed global npm package version, or None."""
|
|
499
519
|
try:
|
|
500
|
-
result =
|
|
501
|
-
["
|
|
520
|
+
result = _run_npm(
|
|
521
|
+
["list", "-g", package_name, "--json", "--depth=0"],
|
|
502
522
|
capture_output=True, text=True, timeout=15,
|
|
503
523
|
)
|
|
504
524
|
# npm list returns exit 1 with valid JSON for peer dep issues;
|
|
@@ -521,8 +541,8 @@ def _get_npm_global_version(package_name: str) -> str | None:
|
|
|
521
541
|
def _get_npm_registry_version(package_name: str) -> str | None:
|
|
522
542
|
"""Return the latest version of a package from the npm registry."""
|
|
523
543
|
try:
|
|
524
|
-
result =
|
|
525
|
-
["
|
|
544
|
+
result = _run_npm(
|
|
545
|
+
["view", package_name, "version"],
|
|
526
546
|
capture_output=True, text=True, timeout=15,
|
|
527
547
|
)
|
|
528
548
|
if result.returncode == 0 and result.stdout.strip():
|
|
@@ -593,8 +613,8 @@ def _update_runtime_dependencies(progress_fn=None) -> list[dict]:
|
|
|
593
613
|
# Install it
|
|
594
614
|
_emit_progress(progress_fn, f"Installing {name}...")
|
|
595
615
|
try:
|
|
596
|
-
r =
|
|
597
|
-
["
|
|
616
|
+
r = _run_npm(
|
|
617
|
+
["install", "-g", name],
|
|
598
618
|
capture_output=True, text=True, timeout=120,
|
|
599
619
|
)
|
|
600
620
|
if r.returncode == 0:
|
|
@@ -638,8 +658,8 @@ def _update_runtime_dependencies(progress_fn=None) -> list[dict]:
|
|
|
638
658
|
# Update
|
|
639
659
|
_emit_progress(progress_fn, f"Updating {name} {old_version} -> {latest_version or 'latest'}...")
|
|
640
660
|
try:
|
|
641
|
-
r =
|
|
642
|
-
["
|
|
661
|
+
r = _run_npm(
|
|
662
|
+
["update", "-g", name],
|
|
643
663
|
capture_output=True, text=True, timeout=120,
|
|
644
664
|
)
|
|
645
665
|
if r.returncode == 0:
|
|
@@ -876,8 +896,8 @@ def _rollback_npm_package(target_version: str) -> str | None:
|
|
|
876
896
|
from our own pre-update backup — no need for postinstall migration.
|
|
877
897
|
"""
|
|
878
898
|
try:
|
|
879
|
-
result =
|
|
880
|
-
["
|
|
899
|
+
result = _run_npm(
|
|
900
|
+
["install", "-g", f"nexo-brain@{target_version}"],
|
|
881
901
|
capture_output=True, text=True, timeout=120,
|
|
882
902
|
env={**os.environ, "NEXO_SKIP_POSTINSTALL": "1", "NEXO_HOME": str(NEXO_HOME)},
|
|
883
903
|
)
|
|
@@ -1049,8 +1069,8 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1049
1069
|
# 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
|
|
1050
1070
|
try:
|
|
1051
1071
|
_emit_progress(progress_fn, "Downloading and applying the latest npm package...")
|
|
1052
|
-
result =
|
|
1053
|
-
["
|
|
1072
|
+
result = _run_npm(
|
|
1073
|
+
["update", "-g", "nexo-brain"],
|
|
1054
1074
|
capture_output=True, text=True, timeout=120,
|
|
1055
1075
|
env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
|
|
1056
1076
|
)
|
|
@@ -219,12 +219,9 @@ def analyze_session(
|
|
|
219
219
|
# Fallback: if Claude returned text instead of JSON, ask a short conversion call
|
|
220
220
|
if not parsed and len(output.strip()) > 50:
|
|
221
221
|
print(f" Got text instead of JSON ({len(output)} chars). Converting...")
|
|
222
|
-
convert_prompt = (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
f"Analysis:\n{output[:8000]}\n\n"
|
|
226
|
-
f"Required schema: session_id, findings[], emotional_timeline[], "
|
|
227
|
-
f"abandoned_projects[], skill_candidates[], productivity_score, protocol_summary"
|
|
222
|
+
convert_prompt = render_core_prompt(
|
|
223
|
+
"deep-sleep-extract-json-conversion",
|
|
224
|
+
analysis=output[:8000],
|
|
228
225
|
)
|
|
229
226
|
convert_result = run_automation_prompt(
|
|
230
227
|
convert_prompt,
|
package/src/t4_llm_gate.py
CHANGED
|
@@ -23,6 +23,8 @@ import hashlib
|
|
|
23
23
|
import time
|
|
24
24
|
from typing import Any, Callable, Optional
|
|
25
25
|
|
|
26
|
+
from core_prompts import render_core_prompt
|
|
27
|
+
|
|
26
28
|
_TTL_SECONDS = 5 * 60
|
|
27
29
|
_MAX_ENTRIES = 256
|
|
28
30
|
|
|
@@ -75,99 +77,30 @@ def classify_with_llm(
|
|
|
75
77
|
return verdict
|
|
76
78
|
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
"R15":
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"files). Answer \"yes\" if a context pull is required, \"no\" if the "
|
|
84
|
-
"turn is conversational / off-topic / meta."
|
|
85
|
-
),
|
|
86
|
-
"positives": [
|
|
87
|
-
"User: \"Vamos a arreglar el bug del checkout\" → yes",
|
|
88
|
-
"User: \"Hazme un refactor del login de CanaRirural\" → yes",
|
|
89
|
-
"User: \"Revisa la PR del orchestrator\" → yes",
|
|
90
|
-
],
|
|
91
|
-
"negatives": [
|
|
92
|
-
"User: \"qué hora es\" → no",
|
|
93
|
-
"User: \"gracias, ya está\" → no",
|
|
94
|
-
"User: \"dime un chiste\" → no",
|
|
95
|
-
],
|
|
96
|
-
},
|
|
97
|
-
"R23e": {
|
|
98
|
-
"instruction": (
|
|
99
|
-
"Decide whether the proposed `git push --force` command would actually "
|
|
100
|
-
"rewrite a protected branch (main, master, production, release-*). "
|
|
101
|
-
"Answer \"yes\" if the target is protected, \"no\" if it targets a "
|
|
102
|
-
"personal branch, a temporary backup branch, or is clearly a local-only "
|
|
103
|
-
"operation that the user explicitly authorised."
|
|
104
|
-
),
|
|
105
|
-
"positives": [
|
|
106
|
-
"`git push --force origin main` → yes",
|
|
107
|
-
"`git push -f origin production` → yes",
|
|
108
|
-
"`git push --force origin release-2026-04` → yes",
|
|
109
|
-
],
|
|
110
|
-
"negatives": [
|
|
111
|
-
"`git push --force origin my-feature` → no",
|
|
112
|
-
"`git push --force-with-lease origin main` → no",
|
|
113
|
-
"`git push --force origin backup-before-refactor` → no",
|
|
114
|
-
],
|
|
115
|
-
},
|
|
116
|
-
"R23f": {
|
|
117
|
-
"instruction": (
|
|
118
|
-
"Decide whether the SQL statement performs DELETE/UPDATE without a "
|
|
119
|
-
"WHERE clause against a production table. Answer \"yes\" if it is an "
|
|
120
|
-
"unscoped destructive write, \"no\" if it is a well-scoped delete, a "
|
|
121
|
-
"DDL command, or a scratch table known to be temporary."
|
|
122
|
-
),
|
|
123
|
-
"positives": [
|
|
124
|
-
"`DELETE FROM orders` → yes",
|
|
125
|
-
"`UPDATE users SET active=0` → yes",
|
|
126
|
-
"`DELETE FROM clients` → yes",
|
|
127
|
-
],
|
|
128
|
-
"negatives": [
|
|
129
|
-
"`DELETE FROM orders WHERE id = 123` → no",
|
|
130
|
-
"`TRUNCATE TABLE tmp_scratch` → no",
|
|
131
|
-
"`UPDATE users SET last_login = NOW() WHERE id = 42` → no",
|
|
132
|
-
],
|
|
133
|
-
},
|
|
134
|
-
"R23h": {
|
|
135
|
-
"instruction": (
|
|
136
|
-
"Decide whether the shebang of the script disagrees with the "
|
|
137
|
-
"interpreter that will actually be invoked. Answer \"yes\" if the "
|
|
138
|
-
"mismatch will break execution, \"no\" otherwise."
|
|
139
|
-
),
|
|
140
|
-
"positives": [
|
|
141
|
-
"\"#!/usr/bin/env python3\" + bash body with `for i in $(seq 1 10); do` → yes",
|
|
142
|
-
"\"#!/bin/sh\" + bashisms like `[[ ${foo} == \"bar\" ]]` → yes",
|
|
143
|
-
"\"#!/usr/bin/env node\" + bash heredoc body → yes",
|
|
144
|
-
],
|
|
145
|
-
"negatives": [
|
|
146
|
-
"\"#!/usr/bin/env python3\" + real Python body → no",
|
|
147
|
-
"\"#!/bin/bash\" + bash arrays → no",
|
|
148
|
-
"Python script with no shebang at all → no",
|
|
149
|
-
],
|
|
150
|
-
},
|
|
80
|
+
PROMPT_TEMPLATE_NAMES: dict[str, str] = {
|
|
81
|
+
"R15": "t4-r15-project-context-gate",
|
|
82
|
+
"R23e": "t4-r23e-force-push-gate",
|
|
83
|
+
"R23f": "t4-r23f-db-no-where-gate",
|
|
84
|
+
"R23h": "t4-r23h-shebang-mismatch-gate",
|
|
151
85
|
}
|
|
152
86
|
|
|
153
87
|
|
|
154
88
|
def build_prompt(rule_id: str, *, span: str = "", context: str = "") -> Optional[str]:
|
|
155
|
-
|
|
156
|
-
if
|
|
89
|
+
template_name = PROMPT_TEMPLATE_NAMES.get(rule_id)
|
|
90
|
+
if template_name is None:
|
|
157
91
|
return None
|
|
158
|
-
|
|
159
|
-
["+ " + e for e in p["positives"]] + ["- " + e for e in p["negatives"]]
|
|
160
|
-
)
|
|
161
|
-
body = p["instruction"] + "\n\nExamples:\n" + examples
|
|
162
|
-
body += "\n\nNow decide. Input:\n" + (span or "")
|
|
92
|
+
context_section = ""
|
|
163
93
|
if context:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
94
|
+
context_section = "\n\nAdditional context:\n" + context
|
|
95
|
+
return render_core_prompt(
|
|
96
|
+
template_name,
|
|
97
|
+
span=(span or ""),
|
|
98
|
+
context_section=context_section,
|
|
99
|
+
)
|
|
167
100
|
|
|
168
101
|
|
|
169
102
|
__all__ = [
|
|
170
|
-
"
|
|
103
|
+
"PROMPT_TEMPLATE_NAMES",
|
|
171
104
|
"build_prompt",
|
|
172
105
|
"classify_with_llm",
|
|
173
106
|
"_cache",
|
|
@@ -11,6 +11,7 @@ import tarfile
|
|
|
11
11
|
import tempfile
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
import re
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
@@ -75,6 +76,32 @@ def _runtime_version() -> str:
|
|
|
75
76
|
return "?"
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
def _parse_version_tuple(value: str) -> tuple[int, ...] | None:
|
|
80
|
+
text = str(value or "").strip()
|
|
81
|
+
if not text:
|
|
82
|
+
return None
|
|
83
|
+
parts: list[int] = []
|
|
84
|
+
for token in text.split("."):
|
|
85
|
+
match = re.match(r"^(\d+)", token.strip())
|
|
86
|
+
if not match:
|
|
87
|
+
return None
|
|
88
|
+
parts.append(int(match.group(1)))
|
|
89
|
+
return tuple(parts) if parts else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _version_relation(bundle_version: str, current_version: str) -> str:
|
|
93
|
+
bundle_tuple = _parse_version_tuple(bundle_version)
|
|
94
|
+
current_tuple = _parse_version_tuple(current_version)
|
|
95
|
+
if not bundle_tuple or not current_tuple:
|
|
96
|
+
return "unknown"
|
|
97
|
+
width = max(len(bundle_tuple), len(current_tuple))
|
|
98
|
+
bundle_norm = bundle_tuple + (0,) * (width - len(bundle_tuple))
|
|
99
|
+
current_norm = current_tuple + (0,) * (width - len(current_tuple))
|
|
100
|
+
if bundle_norm == current_norm:
|
|
101
|
+
return "match"
|
|
102
|
+
return "bundle_newer" if bundle_norm > current_norm else "bundle_older"
|
|
103
|
+
|
|
104
|
+
|
|
78
105
|
def _sqlite_backup(src: Path, dest: Path) -> None:
|
|
79
106
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
80
107
|
src_conn = sqlite3.connect(str(src))
|
|
@@ -183,6 +210,56 @@ def _safe_extract(archive_path: Path, dest_dir: Path) -> None:
|
|
|
183
210
|
target.chmod(member.mode & 0o777)
|
|
184
211
|
|
|
185
212
|
|
|
213
|
+
def _stage_bundle(archive_path: Path) -> tuple[Path, Path, dict]:
|
|
214
|
+
STAGING_DIR.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
stage_dir = Path(tempfile.mkdtemp(prefix="nexo-import-", dir=str(STAGING_DIR)))
|
|
216
|
+
try:
|
|
217
|
+
_safe_extract(archive_path, stage_dir)
|
|
218
|
+
bundle_root = stage_dir / "bundle"
|
|
219
|
+
manifest_path = bundle_root / "manifest.json"
|
|
220
|
+
if not manifest_path.is_file():
|
|
221
|
+
raise ValueError("bundle manifest missing")
|
|
222
|
+
manifest = json.loads(manifest_path.read_text())
|
|
223
|
+
if manifest.get("kind") != "nexo-user-data-bundle":
|
|
224
|
+
raise ValueError(f"unsupported bundle kind: {manifest.get('kind', 'unknown')}")
|
|
225
|
+
return stage_dir, bundle_root, manifest
|
|
226
|
+
except Exception:
|
|
227
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _inspect_manifest(manifest: dict, archive_path: Path) -> dict:
|
|
232
|
+
current_version = _runtime_version()
|
|
233
|
+
bundle_version = str(manifest.get("version") or "?")
|
|
234
|
+
section_names = sorted(
|
|
235
|
+
str(name).strip()
|
|
236
|
+
for name in (manifest.get("sections") or {}).keys()
|
|
237
|
+
if str(name).strip()
|
|
238
|
+
)
|
|
239
|
+
warning_codes: list[str] = []
|
|
240
|
+
relation = _version_relation(bundle_version, current_version)
|
|
241
|
+
if relation == "bundle_newer":
|
|
242
|
+
warning_codes.append("bundle_newer")
|
|
243
|
+
elif relation == "bundle_older":
|
|
244
|
+
warning_codes.append("bundle_older")
|
|
245
|
+
elif relation == "unknown" and bundle_version != current_version:
|
|
246
|
+
warning_codes.append("version_unknown")
|
|
247
|
+
if not section_names:
|
|
248
|
+
warning_codes.append("no_sections")
|
|
249
|
+
return {
|
|
250
|
+
"ok": True,
|
|
251
|
+
"path": str(archive_path),
|
|
252
|
+
"kind": str(manifest.get("kind") or ""),
|
|
253
|
+
"bundle_version": bundle_version,
|
|
254
|
+
"current_version": current_version,
|
|
255
|
+
"created_at": str(manifest.get("created_at") or ""),
|
|
256
|
+
"section_names": section_names,
|
|
257
|
+
"section_count": len(section_names),
|
|
258
|
+
"version_relation": relation,
|
|
259
|
+
"warning_codes": warning_codes,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
186
263
|
def _load_personal_scripts() -> tuple[list[dict], list[dict]]:
|
|
187
264
|
from script_registry import classify_scripts_dir, discover_personal_schedules
|
|
188
265
|
|
|
@@ -196,10 +273,11 @@ def _load_personal_scripts() -> tuple[list[dict], list[dict]]:
|
|
|
196
273
|
return scripts, schedules
|
|
197
274
|
|
|
198
275
|
|
|
199
|
-
def export_user_bundle(output_path: str = "") -> dict:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
276
|
+
def export_user_bundle(output_path: str = "", *, enforce_rate_limit: bool = True) -> dict:
|
|
277
|
+
if enforce_rate_limit:
|
|
278
|
+
err = _check_export_rate_limit()
|
|
279
|
+
if err is not None:
|
|
280
|
+
return {"ok": False, "error": err, "rate_limited": True}
|
|
203
281
|
output = Path(output_path).expanduser() if output_path.strip() else (EXPORTS_DIR / f"nexo-user-data-{_now_stamp()}.tar.gz")
|
|
204
282
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
205
283
|
STAGING_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -286,35 +364,42 @@ def export_user_bundle(output_path: str = "") -> dict:
|
|
|
286
364
|
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
287
365
|
|
|
288
366
|
|
|
367
|
+
def inspect_user_bundle(bundle_path: str) -> dict:
|
|
368
|
+
archive_path = Path(bundle_path).expanduser()
|
|
369
|
+
if not archive_path.is_file():
|
|
370
|
+
return {"ok": False, "error": f"bundle not found: {archive_path}", "path": str(archive_path)}
|
|
371
|
+
|
|
372
|
+
stage_dir: Path | None = None
|
|
373
|
+
try:
|
|
374
|
+
stage_dir, _bundle_root, manifest = _stage_bundle(archive_path)
|
|
375
|
+
return _inspect_manifest(manifest, archive_path)
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
return {"ok": False, "error": str(exc), "path": str(archive_path)}
|
|
378
|
+
finally:
|
|
379
|
+
if stage_dir is not None:
|
|
380
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
381
|
+
|
|
382
|
+
|
|
289
383
|
def import_user_bundle(bundle_path: str) -> dict:
|
|
290
384
|
archive_path = Path(bundle_path).expanduser()
|
|
291
385
|
if not archive_path.is_file():
|
|
292
386
|
return {"ok": False, "error": f"bundle not found: {archive_path}"}
|
|
293
387
|
|
|
388
|
+
inspection = inspect_user_bundle(str(archive_path))
|
|
389
|
+
if not inspection.get("ok"):
|
|
390
|
+
return inspection
|
|
391
|
+
|
|
294
392
|
backups_dir = paths.backups_dir()
|
|
295
393
|
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
296
394
|
safety_backup = backups_dir / f"pre-import-user-data-{_now_stamp()}.tar.gz"
|
|
297
|
-
safety_result = export_user_bundle(str(safety_backup))
|
|
395
|
+
safety_result = export_user_bundle(str(safety_backup), enforce_rate_limit=False)
|
|
298
396
|
if not safety_result.get("ok"):
|
|
299
397
|
return {"ok": False, "error": "failed to create safety backup", "safety_backup": str(safety_backup)}
|
|
300
398
|
|
|
301
|
-
|
|
302
|
-
stage_dir = Path(tempfile.mkdtemp(prefix="nexo-import-", dir=str(STAGING_DIR)))
|
|
399
|
+
stage_dir: Path | None = None
|
|
303
400
|
|
|
304
401
|
try:
|
|
305
|
-
|
|
306
|
-
bundle_root = stage_dir / "bundle"
|
|
307
|
-
manifest_path = bundle_root / "manifest.json"
|
|
308
|
-
if not manifest_path.is_file():
|
|
309
|
-
return {"ok": False, "error": "bundle manifest missing", "safety_backup": str(safety_backup)}
|
|
310
|
-
|
|
311
|
-
manifest = json.loads(manifest_path.read_text())
|
|
312
|
-
if manifest.get("kind") != "nexo-user-data-bundle":
|
|
313
|
-
return {
|
|
314
|
-
"ok": False,
|
|
315
|
-
"error": f"unsupported bundle kind: {manifest.get('kind', 'unknown')}",
|
|
316
|
-
"safety_backup": str(safety_backup),
|
|
317
|
-
}
|
|
402
|
+
stage_dir, bundle_root, manifest = _stage_bundle(archive_path)
|
|
318
403
|
|
|
319
404
|
restored: dict[str, dict] = {}
|
|
320
405
|
|
|
@@ -368,6 +453,9 @@ def import_user_bundle(bundle_path: str) -> dict:
|
|
|
368
453
|
"path": str(archive_path),
|
|
369
454
|
"kind": manifest.get("kind"),
|
|
370
455
|
"bundle_version": manifest.get("version"),
|
|
456
|
+
"current_version": inspection.get("current_version"),
|
|
457
|
+
"version_relation": inspection.get("version_relation"),
|
|
458
|
+
"warning_codes": inspection.get("warning_codes", []),
|
|
371
459
|
"safety_backup": str(safety_backup),
|
|
372
460
|
"restored": restored,
|
|
373
461
|
"skill_sync": skill_sync_result,
|
|
@@ -382,4 +470,5 @@ def import_user_bundle(bundle_path: str) -> dict:
|
|
|
382
470
|
"safety_backup": str(safety_backup),
|
|
383
471
|
}
|
|
384
472
|
finally:
|
|
385
|
-
|
|
473
|
+
if stage_dir is not None:
|
|
474
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Convert the following analysis into the exact JSON schema required. Return ONLY the JSON object, nothing else.
|
|
2
|
+
|
|
3
|
+
Analysis:
|
|
4
|
+
[[analysis]]
|
|
5
|
+
|
|
6
|
+
Required schema: session_id, findings[], emotional_timeline[], abandoned_projects[], skill_candidates[], productivity_score, protocol_summary
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
R13 pre-Edit guard: a write-class tool ([[tool_name]]) is about to touch [[path_str]] without a recent nexo_guard_check on that path. Execute nexo_guard_check(files='[[first_file]]') first, review any blocking learnings, then acknowledge via nexo_task_acknowledge_guard before the next edit. Do not produce visible text for this reminder — act on it. After handling the silent action, continue with your previous task; this reminder is silent context, not a new instruction.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Decide whether the user just started work on a project without the agent having pulled the project context (atlas, git log, project files). Answer "yes" if a context pull is required, "no" if the turn is conversational / off-topic / meta.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
+ User: "Vamos a arreglar el bug del checkout" -> yes
|
|
5
|
+
+ User: "Hazme un refactor del login de CanaRirural" -> yes
|
|
6
|
+
+ User: "Revisa la PR del orchestrator" -> yes
|
|
7
|
+
- User: "qué hora es" -> no
|
|
8
|
+
- User: "gracias, ya está" -> no
|
|
9
|
+
- User: "dime un chiste" -> no
|
|
10
|
+
|
|
11
|
+
Now decide. Input:
|
|
12
|
+
[[span]][[context_section]]
|
|
13
|
+
|
|
14
|
+
Answer exactly "yes" or "no".
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Decide whether the proposed `git push --force` command would actually rewrite a protected branch (main, master, production, release-*). Answer "yes" if the target is protected, "no" if it targets a personal branch, a temporary backup branch, or is clearly a local-only operation that the user explicitly authorised.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
+ `git push --force origin main` -> yes
|
|
5
|
+
+ `git push -f origin production` -> yes
|
|
6
|
+
+ `git push --force origin release-2026-04` -> yes
|
|
7
|
+
- `git push --force origin my-feature` -> no
|
|
8
|
+
- `git push --force-with-lease origin main` -> no
|
|
9
|
+
- `git push --force origin backup-before-refactor` -> no
|
|
10
|
+
|
|
11
|
+
Now decide. Input:
|
|
12
|
+
[[span]][[context_section]]
|
|
13
|
+
|
|
14
|
+
Answer exactly "yes" or "no".
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Decide whether the SQL statement performs DELETE/UPDATE without a WHERE clause against a production table. Answer "yes" if it is an unscoped destructive write, "no" if it is a well-scoped delete, a DDL command, or a scratch table known to be temporary.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
+ `DELETE FROM orders` -> yes
|
|
5
|
+
+ `UPDATE users SET active=0` -> yes
|
|
6
|
+
+ `DELETE FROM clients` -> yes
|
|
7
|
+
- `DELETE FROM orders WHERE id = 123` -> no
|
|
8
|
+
- `TRUNCATE TABLE tmp_scratch` -> no
|
|
9
|
+
- `UPDATE users SET last_login = NOW() WHERE id = 42` -> no
|
|
10
|
+
|
|
11
|
+
Now decide. Input:
|
|
12
|
+
[[span]][[context_section]]
|
|
13
|
+
|
|
14
|
+
Answer exactly "yes" or "no".
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Decide whether the shebang of the script disagrees with the interpreter that will actually be invoked. Answer "yes" if the mismatch will break execution, "no" otherwise.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
+ "#!/usr/bin/env python3" + bash body with `for i in $(seq 1 10); do` -> yes
|
|
5
|
+
+ "#!/bin/sh" + bashisms like `[[ ${foo} == "bar" ]]` -> yes
|
|
6
|
+
+ "#!/usr/bin/env node" + bash heredoc body -> yes
|
|
7
|
+
- "#!/usr/bin/env python3" + real Python body -> no
|
|
8
|
+
- "#!/bin/bash" + bash arrays -> no
|
|
9
|
+
- Python script with no shebang at all -> no
|
|
10
|
+
|
|
11
|
+
Now decide. Input:
|
|
12
|
+
[[span]][[context_section]]
|
|
13
|
+
|
|
14
|
+
Answer exactly "yes" or "no".
|