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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.2",
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.2` is the current packaged-runtime line. It consolidates the prompt catalog migration that had already landed above `7.1.1`, fixes lazy-loading gaps that still affected standalone runtime paths (`agent_runner`, email account DB access, and Deep Sleep extract), and refreshes the public release surfaces so the open-source Brain and the companion Desktop client describe the same shipped contract again. The companion NEXO Desktop client (v0.22.2, closed-source distributed separately) embeds the same release line for its guided bootstrap and repair flow.
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.2",
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",
@@ -20,7 +20,17 @@ import threading
20
20
  import time
21
21
  from pathlib import Path
22
22
 
23
- from product_mode import enforce_desktop_product_contract
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
- "R13 pre-Edit guard: a write-class tool (" + tool_name + ") is about "
529
- "to touch " + path_str + " without a recent nexo_guard_check on that "
530
- "path. Execute nexo_guard_check(files='" + (files[0] if files else "") + "') "
531
- "first, review any blocking learnings, then acknowledge via "
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)
@@ -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 = subprocess.run(
133
- ["npm", "root", "-g"],
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 = subprocess.run(
501
- ["npm", "list", "-g", package_name, "--json", "--depth=0"],
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 = subprocess.run(
525
- ["npm", "view", package_name, "version"],
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 = subprocess.run(
597
- ["npm", "install", "-g", name],
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 = subprocess.run(
642
- ["npm", "update", "-g", name],
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 = subprocess.run(
880
- ["npm", "install", "-g", f"nexo-brain@{target_version}"],
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 = subprocess.run(
1053
- ["npm", "update", "-g", "nexo-brain"],
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
- f"Convert the following analysis into the exact JSON schema required. "
224
- f"Return ONLY the JSON object, nothing else.\n\n"
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,
@@ -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
- PROMPTS: dict[str, dict] = {
79
- "R15": {
80
- "instruction": (
81
- "Decide whether the user just started work on a project without the "
82
- "agent having pulled the project context (atlas, git log, project "
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
- p = PROMPTS.get(rule_id)
156
- if p is None:
89
+ template_name = PROMPT_TEMPLATE_NAMES.get(rule_id)
90
+ if template_name is None:
157
91
  return None
158
- examples = "\n".join(
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
- body += "\n\nAdditional context:\n" + context
165
- body += "\n\nAnswer exactly \"yes\" or \"no\"."
166
- return body
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
- "PROMPTS",
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
- err = _check_export_rate_limit()
201
- if err is not None:
202
- return {"ok": False, "error": err, "rate_limited": True}
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
- STAGING_DIR.mkdir(parents=True, exist_ok=True)
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
- _safe_extract(archive_path, stage_dir)
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
- shutil.rmtree(stage_dir, ignore_errors=True)
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".