nexo-brain 7.30.10 → 7.30.12

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.30.10",
3
+ "version": "7.30.12",
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,11 @@
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.30.10` is the current packaged-runtime line. Patch release over v7.30.9 - packaged `nexo update` now stamps the verified repair baseline after import verification, including same-version maintenance runs.
21
+ Version `7.30.12` is the current packaged-runtime line. Patch release over v7.30.11 - Desktop-managed email credentials now resolve through the shared credential helper, so Email NEXO migrations and CLI reads use keychain-backed markers before legacy SQLite fallback.
22
+
23
+ Previously in `7.30.11`: patch release over v7.30.10 - the installer and npm postinstall path now stamp the verified repair baseline too, so the first update from older packaged installs is covered without a manual second run.
24
+
25
+ Previously in `7.30.10`: patch release over v7.30.9 - packaged `nexo update` now stamps the verified repair baseline after import verification, including same-version maintenance runs.
22
26
 
23
27
  Previously in `7.30.9`: patch release over v7.30.8 - post-update self-heal now stamps a verified repair baseline, and doctor release gates distinguish current installation failures from historical operator/session drift.
24
28
 
package/bin/nexo-brain.js CHANGED
@@ -949,6 +949,52 @@ function syncRuntimePackageMetadata(repoRoot = path.join(__dirname, ".."), runti
949
949
  }
950
950
  }
951
951
 
952
+ const REPAIR_BASELINE_FILE = "last-repair-baseline.json";
953
+
954
+ function _runtimeRepairBaselineDir(nexoHome = NEXO_HOME) {
955
+ const canonical = path.join(nexoHome, "runtime", "operations");
956
+ const legacy = path.join(nexoHome, "operations");
957
+ if (!fs.existsSync(path.join(nexoHome, "runtime")) && fs.existsSync(legacy)) {
958
+ return legacy;
959
+ }
960
+ return canonical;
961
+ }
962
+
963
+ function stampRuntimeRepairBaseline(nexoHome = NEXO_HOME, source = "bin.nexo-brain") {
964
+ try {
965
+ const operationsDir = _runtimeRepairBaselineDir(nexoHome);
966
+ fs.mkdirSync(operationsDir, { recursive: true });
967
+ const now = new Date();
968
+ const body = JSON.stringify({
969
+ last_repair_epoch: now.getTime() / 1000,
970
+ last_repair_at: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
971
+ source,
972
+ reason: "verified runtime repair baseline after installer/postinstall repair",
973
+ }, null, 2) + "\n";
974
+ const baselinePath = path.join(operationsDir, REPAIR_BASELINE_FILE);
975
+ fs.writeFileSync(baselinePath, body);
976
+
977
+ const legacyDir = path.join(nexoHome, "operations");
978
+ const legacyPath = path.join(legacyDir, REPAIR_BASELINE_FILE);
979
+ if (legacyPath !== baselinePath && fs.existsSync(legacyDir)) {
980
+ try {
981
+ fs.writeFileSync(legacyPath, body);
982
+ } catch (_) {}
983
+ }
984
+ return { ok: true, path: baselinePath };
985
+ } catch (err) {
986
+ return { ok: false, error: String((err && err.message) || err) };
987
+ }
988
+ }
989
+
990
+ function logRepairBaselineStatus(result) {
991
+ if (result && result.ok) {
992
+ log(" Repair baseline: updated.");
993
+ } else {
994
+ log(` Repair baseline warning: ${(result && result.error) || "unknown error"}`);
995
+ }
996
+ }
997
+
952
998
  function resolveRuntimeConfigDir(nexoHome) {
953
999
  const canonical = path.join(nexoHome, "personal", "config");
954
1000
  const legacy = path.join(nexoHome, "config");
@@ -3286,6 +3332,9 @@ async function runSetup() {
3286
3332
  throw new Error(`Runtime activation failed: ${migActivation.error}`);
3287
3333
  }
3288
3334
  log(` Runtime activation: core/current -> versions/${currentVersion}`);
3335
+ logRepairBaselineStatus(
3336
+ stampRuntimeRepairBaseline(NEXO_HOME, "bin.nexo-brain.migration")
3337
+ );
3289
3338
 
3290
3339
  // Keep the rendered template in-memory for version tracking, but do
3291
3340
  // not drop a loose reference file in NEXO_HOME root.
@@ -3439,6 +3488,9 @@ async function runSetup() {
3439
3488
  if (!syncLayoutFinalize.ok) {
3440
3489
  throw new Error(`F0.6 layout finalization failed: ${syncLayoutFinalize.error}`);
3441
3490
  }
3491
+ logRepairBaselineStatus(
3492
+ stampRuntimeRepairBaseline(NEXO_HOME, "bin.nexo-brain.same-version-repair")
3493
+ );
3442
3494
 
3443
3495
  runDesktopAwareModelWarmup(syncPython, NEXO_HOME, { reason: "repair" });
3444
3496
  logMacPermissionsNotice(NEXO_HOME, syncPython);
@@ -5029,6 +5081,9 @@ See ~/.nexo/ for configuration.
5029
5081
  if (!layoutFinalize.ok) {
5030
5082
  throw new Error(`F0.6 layout finalization failed: ${layoutFinalize.error}`);
5031
5083
  }
5084
+ logRepairBaselineStatus(
5085
+ stampRuntimeRepairBaseline(NEXO_HOME, "bin.nexo-brain.install")
5086
+ );
5032
5087
 
5033
5088
  console.log("");
5034
5089
  const readyMsg = t.ready(operatorName, aliasName);
@@ -13,6 +13,7 @@ const { execFileSync } = require("child_process");
13
13
  const NEXO_HOME = process.env.NEXO_HOME || path.join(require("os").homedir(), ".nexo");
14
14
  const VERSION_FILE = path.join(NEXO_HOME, "version.json");
15
15
  const INSTALLER = path.join(__dirname, "nexo-brain.js");
16
+ const REPAIR_BASELINE_FILE = "last-repair-baseline.json";
16
17
 
17
18
  if (process.env.NEXO_SKIP_POSTINSTALL === "1") {
18
19
  // Called during rollback — skip migration to avoid loops
@@ -31,13 +32,44 @@ function runModelWarmup(reason) {
31
32
  });
32
33
  }
33
34
 
35
+ function runtimeRepairBaselineDir(nexoHome = NEXO_HOME) {
36
+ const canonical = path.join(nexoHome, "runtime", "operations");
37
+ const legacy = path.join(nexoHome, "operations");
38
+ if (!fs.existsSync(path.join(nexoHome, "runtime")) && fs.existsSync(legacy)) {
39
+ return legacy;
40
+ }
41
+ return canonical;
42
+ }
43
+
44
+ function stampRuntimeRepairBaseline(source = "bin.postinstall") {
45
+ const operationsDir = runtimeRepairBaselineDir(NEXO_HOME);
46
+ fs.mkdirSync(operationsDir, { recursive: true });
47
+ const now = new Date();
48
+ const body = JSON.stringify({
49
+ last_repair_epoch: now.getTime() / 1000,
50
+ last_repair_at: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
51
+ source,
52
+ reason: "verified runtime repair baseline after installer/postinstall repair",
53
+ }, null, 2) + "\n";
54
+ const baselinePath = path.join(operationsDir, REPAIR_BASELINE_FILE);
55
+ fs.writeFileSync(baselinePath, body);
56
+
57
+ const legacyDir = path.join(NEXO_HOME, "operations");
58
+ const legacyPath = path.join(legacyDir, REPAIR_BASELINE_FILE);
59
+ if (legacyPath !== baselinePath && fs.existsSync(legacyDir)) {
60
+ try {
61
+ fs.writeFileSync(legacyPath, body);
62
+ } catch (_) {}
63
+ }
64
+ }
65
+
34
66
  if (fs.existsSync(VERSION_FILE)) {
35
67
  // Existing installation — run auto-migration silently
36
68
  const installed = JSON.parse(fs.readFileSync(VERSION_FILE, "utf8"));
37
69
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
38
70
 
39
71
  if (installed.version === pkg.version) {
40
- // Same version, nothing to do
72
+ stampRuntimeRepairBaseline("bin.postinstall.same-version");
41
73
  process.exit(0);
42
74
  }
43
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.10",
3
+ "version": "7.30.12",
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/cli_email.py CHANGED
@@ -21,7 +21,6 @@ from __future__ import annotations
21
21
  import getpass
22
22
  import json
23
23
  import sys
24
- import time
25
24
  from typing import Any
26
25
 
27
26
 
@@ -74,30 +73,16 @@ def _sent_folder_from_account(account: dict | None) -> str:
74
73
 
75
74
 
76
75
  def _store_credential(service: str, key: str, value: str) -> None:
77
- """Write password to the `credentials` table (simple cleartext by
78
- default upgrading to keychain is a v7 follow-up). Never echo the
79
- password back to stdout."""
80
- from db._core import get_db
81
- conn = get_db()
82
- now = time.time()
83
- conn.execute(
84
- """
85
- INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
86
- VALUES (?, ?, ?, 'email account password (nexo email setup)', ?, ?)
87
- ON CONFLICT(service, key) DO UPDATE SET
88
- value = excluded.value,
89
- updated_at = excluded.updated_at
90
- """,
91
- (service, key, value, now, now),
92
- )
93
- conn.commit()
76
+ """Write password without echoing it back to stdout."""
77
+ from email_credentials import store_email_credential
78
+
79
+ store_email_credential(service, key, value, "email account password (nexo email setup)")
94
80
 
95
81
 
96
82
  def _delete_credential(service: str, key: str) -> None:
97
- from db._core import get_db
98
- conn = get_db()
99
- conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
100
- conn.commit()
83
+ from email_credentials import delete_email_credential
84
+
85
+ delete_email_credential(service, key)
101
86
 
102
87
 
103
88
  def cmd_email_setup(args) -> int:
@@ -41,24 +41,11 @@ LEGACY_CONFIG_PATH = _legacy_config_path()
41
41
 
42
42
 
43
43
  def _get_credential(service: str, key: str) -> str:
44
- """Fetch a password from the credentials table. Returns empty string
45
- on any miss so the caller can log-and-skip instead of crashing a cron.
46
- """
47
- if not service or not key:
48
- return ""
44
+ """Fetch a password from keyring marker or legacy credentials table."""
49
45
  try:
50
- from db._core import get_db
51
- except Exception: # pragma: no cover
52
- return ""
53
- try:
54
- conn = get_db()
55
- row = conn.execute(
56
- "SELECT value FROM credentials WHERE service = ? AND key = ?",
57
- (service, key),
58
- ).fetchone()
59
- if row is None:
60
- return ""
61
- return str(row[0] or "")
46
+ from email_credentials import read_email_credential
47
+
48
+ return read_email_credential(service, key)
62
49
  except Exception as exc: # pragma: no cover
63
50
  _logger.warning("credential lookup failed for %s/%s: %s", service, key, exc)
64
51
  return ""
@@ -0,0 +1,159 @@
1
+ """Credential storage for email account passwords.
2
+
3
+ Email account rows keep only ``credential_service`` + ``credential_key``.
4
+ Historically the referenced ``credentials.value`` column stored the password
5
+ directly. New writes try to place the secret in the OS keyring and keep a
6
+ ``keyring://...`` marker in SQLite. Legacy plaintext values remain readable so
7
+ older installs keep working until they are rotated or migrated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import os
14
+ import time
15
+ from urllib.parse import quote, unquote
16
+
17
+ KEYRING_SERVICE = "com.nexo.email"
18
+ KEYRING_MARKER_PREFIX = "keyring://"
19
+
20
+
21
+ def _db():
22
+ from db._core import get_db
23
+
24
+ return get_db()
25
+
26
+
27
+ def _marker(service: str, key: str) -> str:
28
+ return f"{KEYRING_MARKER_PREFIX}{quote(service, safe='')}/{quote(key, safe='')}"
29
+
30
+
31
+ def _parse_marker(value: str) -> tuple[str, str] | None:
32
+ if not value.startswith(KEYRING_MARKER_PREFIX):
33
+ return None
34
+ rest = value[len(KEYRING_MARKER_PREFIX):]
35
+ if "/" not in rest:
36
+ return None
37
+ service, key = rest.split("/", 1)
38
+ return unquote(service), unquote(key)
39
+
40
+
41
+ def _account_name(service: str, key: str) -> str:
42
+ return f"{service}:{key}"
43
+
44
+
45
+ def _keyring_module():
46
+ try:
47
+ return importlib.import_module("keyring")
48
+ except Exception:
49
+ return None
50
+
51
+
52
+ def _read_stored_value(service: str, key: str) -> str:
53
+ if not service or not key:
54
+ return ""
55
+ row = _db().execute(
56
+ "SELECT value FROM credentials WHERE service = ? AND key = ?",
57
+ (service, key),
58
+ ).fetchone()
59
+ if row is None:
60
+ return ""
61
+ return str(row[0] or "")
62
+
63
+
64
+ def _write_db_value(service: str, key: str, value: str, notes: str) -> None:
65
+ conn = _db()
66
+ now = time.time()
67
+ conn.execute(
68
+ """
69
+ INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
70
+ VALUES (?, ?, ?, ?, ?, ?)
71
+ ON CONFLICT(service, key) DO UPDATE SET
72
+ value = excluded.value,
73
+ notes = excluded.notes,
74
+ updated_at = excluded.updated_at
75
+ """,
76
+ (service, key, value, notes, now, now),
77
+ )
78
+ conn.commit()
79
+
80
+
81
+ def store_email_credential(service: str, key: str, value: str, notes: str = "email account password") -> str:
82
+ """Store an email password and return the SQLite value written.
83
+
84
+ Uses keyring when available. If the keyring backend is unavailable or
85
+ locked, the legacy SQLite plaintext path is used so existing automation
86
+ does not lose email access.
87
+ """
88
+ service = str(service or "").strip()
89
+ key = str(key or "").strip()
90
+ value = str(value or "")
91
+ if not service or not key:
92
+ return ""
93
+
94
+ mode = os.environ.get("NEXO_EMAIL_CREDENTIAL_STORE", "auto").strip().lower()
95
+ keyring = None if mode in {"sqlite", "legacy", "plain", "plaintext"} else _keyring_module()
96
+ if keyring is not None:
97
+ try:
98
+ keyring.set_password(KEYRING_SERVICE, _account_name(service, key), value)
99
+ stored = _marker(service, key)
100
+ _write_db_value(service, key, stored, notes + " (stored in system keyring)")
101
+ return stored
102
+ except Exception:
103
+ pass
104
+
105
+ if mode == "keyring":
106
+ return ""
107
+
108
+ _write_db_value(service, key, value, notes + " (legacy sqlite fallback)")
109
+ return value
110
+
111
+
112
+ def read_email_credential(service: str, key: str) -> str:
113
+ """Resolve an email password from keyring marker or legacy plaintext."""
114
+ stored = _read_stored_value(str(service or "").strip(), str(key or "").strip())
115
+ parsed = _parse_marker(stored)
116
+ if parsed is None:
117
+ return stored
118
+
119
+ keyring = _keyring_module()
120
+ if keyring is None:
121
+ return ""
122
+ try:
123
+ value = keyring.get_password(KEYRING_SERVICE, _account_name(*parsed))
124
+ except Exception:
125
+ return ""
126
+ return str(value or "")
127
+
128
+
129
+ def delete_email_credential(service: str, key: str) -> None:
130
+ service = str(service or "").strip()
131
+ key = str(key or "").strip()
132
+ if not service or not key:
133
+ return
134
+
135
+ parsed = _parse_marker(_read_stored_value(service, key))
136
+ keyring = _keyring_module()
137
+ if parsed is not None and keyring is not None:
138
+ try:
139
+ keyring.delete_password(KEYRING_SERVICE, _account_name(*parsed))
140
+ except Exception:
141
+ pass
142
+
143
+ conn = _db()
144
+ conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
145
+ conn.commit()
146
+
147
+
148
+ def is_keyring_marker(value: str) -> bool:
149
+ return _parse_marker(str(value or "")) is not None
150
+
151
+
152
+ __all__ = [
153
+ "KEYRING_MARKER_PREFIX",
154
+ "KEYRING_SERVICE",
155
+ "delete_email_credential",
156
+ "is_keyring_marker",
157
+ "read_email_credential",
158
+ "store_email_credential",
159
+ ]
@@ -26,7 +26,6 @@ import json
26
26
  import os
27
27
  import re
28
28
  import sys
29
- import time
30
29
  from pathlib import Path
31
30
 
32
31
 
@@ -121,20 +120,14 @@ def main(argv: list[str]) -> int:
121
120
  print(f"[dry-run] credentials[{cred_service}/{cred_key}] = <password>")
122
121
  return 0
123
122
 
124
- # Store credential
125
- from db._core import get_db
126
- conn = get_db()
127
- now = time.time()
128
- conn.execute(
129
- """
130
- INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
131
- VALUES (?, ?, ?, 'migrated from ~/.nexo/nexo-email/config.json (F1)', ?, ?)
132
- ON CONFLICT(service, key) DO UPDATE SET
133
- value = excluded.value, updated_at = excluded.updated_at
134
- """,
135
- (cred_service, cred_key, password, now, now),
123
+ from email_credentials import store_email_credential
124
+
125
+ store_email_credential(
126
+ cred_service,
127
+ cred_key,
128
+ password,
129
+ "migrated from ~/.nexo/nexo-email/config.json (F1)",
136
130
  )
137
- conn.commit()
138
131
 
139
132
  if existing and not args.force:
140
133
  existing_metadata = existing.get("metadata") if isinstance(existing.get("metadata"), dict) else {}
@@ -194,7 +187,7 @@ def main(argv: list[str]) -> int:
194
187
  can_send=True,
195
188
  )
196
189
  print(f"✓ Cuenta agente '{args.label}' migrada ({account.get('email')}).")
197
- print(f" Password guardada en credentials[{cred_service}/{cred_key}].")
190
+ print(f" Password guardada en el almacen de credenciales[{cred_service}/{cred_key}].")
198
191
  print(
199
192
  " Metadata: "
200
193
  f"operator_aliases={len(legacy_operator_aliases)}, trusted_domains={len(trusted)}."