nexo-brain 7.23.5 → 7.23.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.23.5",
3
+ "version": "7.23.7",
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,9 +18,9 @@
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.23.5` is the current packaged-runtime line. Patch over v7.23.4 - `nexo update` keeps external CLI maintenance summary copy in English.
21
+ Version `7.23.6` is the current packaged-runtime line. Patch over v7.23.5 - `nexo update` clears safe legacy `cognitive.db` shadows and keeps superseded archives under runtime backup retention.
22
22
 
23
- Previously in `7.23.4`: patch over v7.23.3 - release tags now fail closed when npm publication fails and OpenClaw lockfile metadata stays synchronized with the release version.
23
+ Previously in `7.23.5`: patch over v7.23.4 - `nexo update` keeps external CLI maintenance summary copy in English.
24
24
 
25
25
  Previously in `7.23.3`: patch over v7.23.2 - Followup runner skips DONE terminal statuses so already-finished followups do not re-enter executable batches.
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.23.5",
3
+ "version": "7.23.7",
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",
@@ -32,6 +32,7 @@ except ModuleNotFoundError as exc:
32
32
  sys.path.insert(0, core_path)
33
33
  from product_mode import enforce_desktop_product_contract
34
34
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
35
+ from cognitive_paths import cleanup_legacy_cognitive_db_artifacts
35
36
 
36
37
  try:
37
38
  from tree_hygiene import is_duplicate_artifact_name
@@ -4747,6 +4748,17 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
4747
4748
  except Exception as exc:
4748
4749
  actions.append(f"legacy-personal-brain-db-stubs-warning:{exc.__class__.__name__}")
4749
4750
 
4751
+ try:
4752
+ cognitive_cleanup = cleanup_legacy_cognitive_db_artifacts(dry_run=False)
4753
+ removed = len(cognitive_cleanup.get("removed", []) or [])
4754
+ archived = len(cognitive_cleanup.get("archived", []) or [])
4755
+ if removed:
4756
+ actions.append(f"legacy-cognitive-db-duplicates-removed:{removed}")
4757
+ if archived:
4758
+ actions.append(f"legacy-cognitive-db-archived:{archived}")
4759
+ except Exception as exc:
4760
+ actions.append(f"legacy-cognitive-db-warning:{exc.__class__.__name__}")
4761
+
4750
4762
  try:
4751
4763
  _emit_progress(progress_fn, "Applying Desktop product contract...")
4752
4764
  contract = enforce_desktop_product_contract(source="runtime_post_sync")
@@ -7,6 +7,7 @@ import json
7
7
  import os
8
8
  import shutil
9
9
  import sqlite3
10
+ import tarfile
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
12
13
  from typing import Any
@@ -68,10 +69,12 @@ def _sha256(path: Path) -> str:
68
69
  def _sqlite_signature(path: Path) -> dict[str, Any]:
69
70
  if not path.exists():
70
71
  return {"exists": False}
72
+ stat = path.stat()
71
73
  signature: dict[str, Any] = {
72
74
  "exists": True,
73
75
  "path": str(path),
74
- "size_bytes": path.stat().st_size,
76
+ "size_bytes": stat.st_size,
77
+ "mtime_epoch": stat.st_mtime,
75
78
  "sha256": _sha256(path),
76
79
  }
77
80
  try:
@@ -101,6 +104,10 @@ def _migration_marker_path() -> Path:
101
104
  return paths.runtime_state_dir() / "cognitive-db-migration.json"
102
105
 
103
106
 
107
+ def _cleanup_marker_path() -> Path:
108
+ return paths.runtime_state_dir() / "cognitive-db-cleanup.jsonl"
109
+
110
+
104
111
  def _write_migration_marker(source: Path, target: Path) -> None:
105
112
  marker = {
106
113
  "at": datetime.now(timezone.utc).isoformat(),
@@ -115,6 +122,190 @@ def _write_migration_marker(source: Path, target: Path) -> None:
115
122
  marker_path.write_text(json.dumps(marker, indent=2, sort_keys=True) + "\n", encoding="utf-8")
116
123
 
117
124
 
125
+ def _append_cleanup_marker(event: dict[str, Any]) -> None:
126
+ marker_path = _cleanup_marker_path()
127
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
128
+ payload = {
129
+ "at": datetime.now(timezone.utc).isoformat(),
130
+ **event,
131
+ }
132
+ with marker_path.open("a", encoding="utf-8") as handle:
133
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
134
+
135
+
136
+ def _sidecar_paths(db_path: Path) -> list[Path]:
137
+ return [db_path, Path(f"{db_path}-wal"), Path(f"{db_path}-shm")]
138
+
139
+
140
+ def _existing_sidecars(db_path: Path) -> list[Path]:
141
+ return [path for path in _sidecar_paths(db_path) if path.exists()]
142
+
143
+
144
+ def _wal_has_uncheckpointed_data(db_path: Path) -> bool:
145
+ wal_path = Path(f"{db_path}-wal")
146
+ try:
147
+ return wal_path.is_file() and wal_path.stat().st_size > 0
148
+ except OSError:
149
+ return True
150
+
151
+
152
+ def _canonical_supersedes_legacy(canonical_sig: dict[str, Any], legacy_sig: dict[str, Any]) -> bool:
153
+ if not canonical_sig.get("exists") or not legacy_sig.get("exists"):
154
+ return False
155
+ if not canonical_sig.get("sqlite_ok") or not legacy_sig.get("sqlite_ok"):
156
+ return False
157
+ if float(canonical_sig.get("mtime_epoch") or 0) < float(legacy_sig.get("mtime_epoch") or 0):
158
+ return False
159
+ canonical_tables = set(canonical_sig.get("tables") or [])
160
+ legacy_tables = set(legacy_sig.get("tables") or [])
161
+ if canonical_tables and legacy_tables and not legacy_tables.issubset(canonical_tables):
162
+ return False
163
+ return True
164
+
165
+
166
+ def _remove_paths(paths_to_remove: list[Path]) -> list[str]:
167
+ removed: list[str] = []
168
+ for path in paths_to_remove:
169
+ try:
170
+ if path.exists():
171
+ path.unlink()
172
+ removed.append(str(path))
173
+ except FileNotFoundError:
174
+ continue
175
+ return removed
176
+
177
+
178
+ def _archive_and_remove_legacy_db(
179
+ legacy_db: Path,
180
+ *,
181
+ canonical_sig: dict[str, Any],
182
+ legacy_sig: dict[str, Any],
183
+ reason: str,
184
+ ) -> dict[str, Any]:
185
+ files = _existing_sidecars(legacy_db)
186
+ backup_root = paths.create_backup_dir("legacy-cognitive-db")
187
+ backup_dir = Path(backup_root)
188
+ archive_path = backup_dir / "cognitive-legacy.tar.gz"
189
+ manifest_path = backup_dir / "manifest.json"
190
+ with tarfile.open(archive_path, "w:gz") as archive:
191
+ for file_path in files:
192
+ archive.add(file_path, arcname=file_path.name)
193
+ with tarfile.open(archive_path, "r:gz") as archive:
194
+ archived_names = sorted(archive.getnames())
195
+ archive_sha = _sha256(archive_path)
196
+ manifest = {
197
+ "reason": reason,
198
+ "source": str(legacy_db),
199
+ "archived_files": [str(path) for path in files],
200
+ "archive": str(archive_path),
201
+ "archive_sha256": archive_sha,
202
+ "archive_members": archived_names,
203
+ "canonical": canonical_sig,
204
+ "legacy": legacy_sig,
205
+ }
206
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
207
+ removed = _remove_paths(files)
208
+ paths.finalize_backup_snapshot(backup_dir)
209
+ _append_cleanup_marker({
210
+ "action": "archive_superseded_legacy",
211
+ "source": str(legacy_db),
212
+ "archive": str(archive_path),
213
+ "archive_sha256": archive_sha,
214
+ "removed": removed,
215
+ "reason": reason,
216
+ })
217
+ return {
218
+ "path": str(legacy_db),
219
+ "action": "archived",
220
+ "archive_path": str(archive_path),
221
+ "manifest_path": str(manifest_path),
222
+ "removed": removed,
223
+ "reason": reason,
224
+ }
225
+
226
+
227
+ def cleanup_legacy_cognitive_db_artifacts(*, dry_run: bool = False) -> dict[str, Any]:
228
+ """Remove or archive safe legacy cognitive DB shadows.
229
+
230
+ Identical legacy duplicates are deleted directly. Divergent legacy DBs are
231
+ only archived when the canonical DB is valid, newer, and has a compatible
232
+ schema. Ambiguous cases are left in place so write callers still block.
233
+ """
234
+ override = _configured_override()
235
+ report: dict[str, Any] = {
236
+ "ok": True,
237
+ "dry_run": dry_run,
238
+ "removed": [],
239
+ "archived": [],
240
+ "skipped": [],
241
+ "errors": [],
242
+ }
243
+ if override is not None:
244
+ report["skipped"].append({"reason": "env_override", "path": str(override)})
245
+ return report
246
+
247
+ canonical = canonical_cognitive_db_path()
248
+ canonical_sig = _sqlite_signature(canonical)
249
+ if not canonical_sig.get("exists"):
250
+ report["skipped"].append({"reason": "canonical_missing", "path": str(canonical)})
251
+ return report
252
+ if not canonical_sig.get("sqlite_ok"):
253
+ report["ok"] = False
254
+ report["skipped"].append({"reason": "canonical_not_sqlite_ok", "path": str(canonical)})
255
+ return report
256
+
257
+ for legacy_db in legacy_cognitive_db_paths():
258
+ legacy_sig = _sqlite_signature(legacy_db)
259
+ if not legacy_sig.get("exists"):
260
+ continue
261
+ files = _existing_sidecars(legacy_db)
262
+ if _wal_has_uncheckpointed_data(legacy_db):
263
+ report["skipped"].append({"path": str(legacy_db), "reason": "legacy_wal_has_data"})
264
+ continue
265
+ if legacy_sig.get("sha256") == canonical_sig.get("sha256"):
266
+ item = {
267
+ "path": str(legacy_db),
268
+ "action": "removed-identical-duplicate",
269
+ "files": [str(path) for path in files],
270
+ }
271
+ if not dry_run:
272
+ item["removed"] = _remove_paths(files)
273
+ _append_cleanup_marker({
274
+ "action": "remove_identical_duplicate",
275
+ "source": str(legacy_db),
276
+ "removed": item["removed"],
277
+ "legacy_sha256": legacy_sig.get("sha256"),
278
+ })
279
+ report["removed"].append(item)
280
+ continue
281
+ if _canonical_supersedes_legacy(canonical_sig, legacy_sig):
282
+ if dry_run:
283
+ report["archived"].append({
284
+ "path": str(legacy_db),
285
+ "action": "would-archive-superseded-legacy",
286
+ "reason": "canonical_newer_schema_compatible",
287
+ })
288
+ continue
289
+ try:
290
+ report["archived"].append(_archive_and_remove_legacy_db(
291
+ legacy_db,
292
+ canonical_sig=canonical_sig,
293
+ legacy_sig=legacy_sig,
294
+ reason="canonical_newer_schema_compatible",
295
+ ))
296
+ except Exception as exc:
297
+ report["ok"] = False
298
+ report["errors"].append({"path": str(legacy_db), "error": str(exc)})
299
+ continue
300
+ report["skipped"].append({
301
+ "path": str(legacy_db),
302
+ "reason": "divergent_requires_manual_review",
303
+ "canonical_mtime_epoch": canonical_sig.get("mtime_epoch"),
304
+ "legacy_mtime_epoch": legacy_sig.get("mtime_epoch"),
305
+ })
306
+ return report
307
+
308
+
118
309
  def audit_cognitive_db_paths() -> dict[str, Any]:
119
310
  canonical = canonical_cognitive_db_path()
120
311
  canonical_sig = _sqlite_signature(canonical)
@@ -173,7 +364,14 @@ def migrate_legacy_cognitive_db_if_needed() -> dict[str, Any]:
173
364
  canonical.parent.mkdir(parents=True, exist_ok=True)
174
365
  shutil.copy2(source, canonical)
175
366
  _write_migration_marker(source, canonical)
176
- return {"migrated": True, "reason": "legacy_copied", "source": str(source), "path": str(canonical)}
367
+ cleanup = cleanup_legacy_cognitive_db_artifacts()
368
+ return {
369
+ "migrated": True,
370
+ "reason": "legacy_copied",
371
+ "source": str(source),
372
+ "path": str(canonical),
373
+ "cleanup": cleanup,
374
+ }
177
375
 
178
376
 
179
377
  def resolve_cognitive_db(*, for_write: bool = True, migrate: bool = True, create_parent: bool = True) -> Path:
@@ -183,6 +381,7 @@ def resolve_cognitive_db(*, for_write: bool = True, migrate: bool = True, create
183
381
  target.parent.mkdir(parents=True, exist_ok=True)
184
382
  if migrate:
185
383
  migrate_legacy_cognitive_db_if_needed()
384
+ cleanup_legacy_cognitive_db_artifacts()
186
385
  audit = audit_cognitive_db_paths()
187
386
  if for_write and audit["status"] == "error":
188
387
  raise CognitiveDbPathConflict(
@@ -191,4 +390,3 @@ def resolve_cognitive_db(*, for_write: bool = True, migrate: bool = True, create
191
390
  + ", ".join(entry["path"] for entry in audit["legacy"] if entry["signature"].get("exists"))
192
391
  )
193
392
  return target
194
-
@@ -31,7 +31,9 @@ if _PARENT not in sys.path:
31
31
 
32
32
  from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
33
33
  from cognitive_paths import resolve_cognitive_db
34
+ from email_contract import email_contract_snapshot
34
35
  import paths
36
+ from tools_credentials import public_credential_records
35
37
 
36
38
  TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
37
39
  STATIC_DIR = Path(__file__).resolve().parent / "static"
@@ -1985,8 +1987,9 @@ async def api_artifacts():
1985
1987
  @app.get("/api/email")
1986
1988
  async def api_email_stats():
1987
1989
  conn = _email_db()
1990
+ contract = email_contract_snapshot()
1988
1991
  if not conn:
1989
- return {"error": "Email DB not found", "stats": {}}
1992
+ return {"error": "Email DB not found", "stats": {}, "contract": contract}
1990
1993
  total = conn.execute("SELECT COUNT(*) FROM emails").fetchone()[0]
1991
1994
  processed = conn.execute("SELECT COUNT(*) FROM emails WHERE status='processed'").fetchone()[0]
1992
1995
  pending = conn.execute("SELECT COUNT(*) FROM emails WHERE status='pending'").fetchone()[0]
@@ -1998,7 +2001,17 @@ async def api_email_stats():
1998
2001
  "FROM emails GROUP BY thread_id ORDER BY last_email DESC LIMIT 15"
1999
2002
  ).fetchall()]
2000
2003
  conn.close()
2001
- return {"stats": {"total": total, "processed": processed, "pending": pending}, "recent": recent, "threads": threads}
2004
+ return {
2005
+ "stats": {"total": total, "processed": processed, "pending": pending},
2006
+ "recent": recent,
2007
+ "threads": threads,
2008
+ "contract": contract,
2009
+ }
2010
+
2011
+
2012
+ @app.get("/api/email/contract")
2013
+ async def api_email_contract():
2014
+ return email_contract_snapshot()
2002
2015
 
2003
2016
 
2004
2017
  # ---------------------------------------------------------------------------
@@ -2007,16 +2020,21 @@ async def api_email_stats():
2007
2020
 
2008
2021
  @app.get("/api/credentials")
2009
2022
  async def api_credentials():
2010
- db = _db()
2011
- conn = db.get_db()
2012
- rows = conn.execute("SELECT service, key, notes, created_at, updated_at FROM credentials ORDER BY service, key").fetchall()
2013
- creds = [dict(r) for r in rows]
2023
+ creds = public_credential_records()
2014
2024
  services = {}
2015
2025
  for c in creds:
2016
- services.setdefault(c["service"], []).append({"key": c["key"], "notes": c.get("notes", "")})
2026
+ services.setdefault(c["service"], []).append(
2027
+ {"key": c["key"], "notes": c.get("notes", ""), "backend": c.get("backend", "db")}
2028
+ )
2017
2029
  return {"credentials": creds, "services": services, "total": len(creds)}
2018
2030
 
2019
2031
 
2032
+ @app.get("/api/change-log/retention")
2033
+ async def api_change_log_retention():
2034
+ db = _db()
2035
+ return db.change_log_retention_policy()
2036
+
2037
+
2020
2038
  # ---------------------------------------------------------------------------
2021
2039
  # Backups
2022
2040
  # ---------------------------------------------------------------------------
@@ -154,7 +154,8 @@ from db._entities import (
154
154
 
155
155
  # Episodic memory
156
156
  from db._episodic import (
157
- cleanup_old_changes, log_change, search_changes, update_change_commit, auto_resolve_followups,
157
+ cleanup_old_changes, change_log_retention_days, change_log_retention_policy,
158
+ log_change, search_changes, update_change_commit, auto_resolve_followups,
158
159
  cleanup_old_decisions, log_decision, update_decision_outcome,
159
160
  get_memory_review_queue, find_decisions_by_context_ref, search_decisions,
160
161
  cleanup_old_diaries, write_session_diary,
@@ -1,22 +1,45 @@
1
1
  from __future__ import annotations
2
2
  """NEXO DB — Episodic module."""
3
- import datetime, time, json
3
+ import datetime, time, json, os
4
4
  from db._core import get_db, now_epoch, _multi_word_like
5
5
  from db._fts import fts_upsert, fts_search
6
6
 
7
7
  # ── Change Log ───────────────────────────────────────────────────
8
8
 
9
- def cleanup_old_changes(retention_days: int = 90) -> int:
9
+ DEFAULT_CHANGE_LOG_RETENTION_DAYS = 90
10
+
11
+
12
+ def change_log_retention_days() -> int:
13
+ """Return the configured change-log retention window in days."""
14
+ raw = os.environ.get("NEXO_CHANGE_LOG_RETENTION_DAYS", str(DEFAULT_CHANGE_LOG_RETENTION_DAYS))
15
+ try:
16
+ return max(1, int(raw))
17
+ except (TypeError, ValueError):
18
+ return DEFAULT_CHANGE_LOG_RETENTION_DAYS
19
+
20
+
21
+ def change_log_retention_policy() -> dict:
22
+ """Public contract for change-log cleanup and its FTS side effects."""
23
+ return {
24
+ "retention_days": change_log_retention_days(),
25
+ "env": "NEXO_CHANGE_LOG_RETENTION_DAYS",
26
+ "default_retention_days": DEFAULT_CHANGE_LOG_RETENTION_DAYS,
27
+ "applies_to": ["change_log", "unified_search:source=change"],
28
+ }
29
+
30
+
31
+ def cleanup_old_changes(retention_days: int | None = None) -> int:
10
32
  """Delete change_log entries older than retention_days. Returns count deleted."""
11
33
  conn = get_db()
34
+ days = change_log_retention_days() if retention_days is None else max(1, int(retention_days))
12
35
  # Get IDs before deleting so we can clean FTS
13
36
  ids = [str(r[0]) for r in conn.execute(
14
37
  "SELECT id FROM change_log WHERE created_at < datetime('now', ?)",
15
- (f"-{retention_days} days",)
38
+ (f"-{days} days",)
16
39
  ).fetchall()]
17
40
  cursor = conn.execute(
18
41
  "DELETE FROM change_log WHERE created_at < datetime('now', ?)",
19
- (f"-{retention_days} days",)
42
+ (f"-{days} days",)
20
43
  )
21
44
  for cid in ids:
22
45
  conn.execute("DELETE FROM unified_search WHERE source = 'change' AND source_id = ?", (cid,))
@@ -668,15 +691,30 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
668
691
  # Excludes: cron jobs, auto-closed crons (0 heartbeats or "Minimal diary").
669
692
  if include_automated:
670
693
  source_clause = ""
694
+ source_params = ()
671
695
  else:
696
+ automated_sources = (
697
+ "auto-close",
698
+ "cron",
699
+ "system",
700
+ "automation",
701
+ "deep-sleep",
702
+ "daily-self-audit",
703
+ "self-audit",
704
+ "watchdog",
705
+ "followup-runner",
706
+ "script",
707
+ )
708
+ placeholders = ",".join("?" for _ in automated_sources)
672
709
  source_clause = (
673
710
  " AND ("
674
- " (source = 'claude' AND summary NOT LIKE '[AUTO-%')"
711
+ f" (COALESCE(source, '') NOT IN ({placeholders}) AND summary NOT LIKE '[AUTO-%')"
675
712
  " OR (source = 'auto-close'"
676
713
  " AND mental_state NOT LIKE '%0 heartbeats%'"
677
714
  " AND mental_state NOT LIKE '%Minimal diary%')"
678
715
  ")"
679
716
  )
717
+ source_params = automated_sources
680
718
 
681
719
  if session_id:
682
720
  rows = conn.execute(
@@ -688,12 +726,12 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
688
726
  f"SELECT * FROM session_diary "
689
727
  f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
690
728
  f"ORDER BY {_diary_order_sql()}",
691
- domain_params
729
+ domain_params + source_params
692
730
  ).fetchall()
693
731
  else:
694
732
  rows = conn.execute(
695
733
  f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY {_diary_order_sql()} LIMIT ?",
696
- domain_params + (last_n,)
734
+ domain_params + source_params + (last_n,)
697
735
  ).fetchall()
698
736
  return [dict(r) for r in rows]
699
737
 
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ """Email runtime contract: account config is separate from monitor events."""
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ import paths
9
+ from db import get_db
10
+
11
+
12
+ def _table_exists(conn, table: str) -> bool:
13
+ row = conn.execute(
14
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
15
+ (table,),
16
+ ).fetchone()
17
+ return row is not None
18
+
19
+
20
+ def _count(conn, sql: str, params: tuple = ()) -> int:
21
+ try:
22
+ return int(conn.execute(sql, params).fetchone()[0])
23
+ except Exception:
24
+ return 0
25
+
26
+
27
+ def _email_event_db_path() -> Path:
28
+ return paths.nexo_email_dir() / "nexo-email.db"
29
+
30
+
31
+ def _accounts_config_snapshot() -> dict:
32
+ conn = get_db()
33
+ exists = _table_exists(conn, "email_accounts")
34
+ out = {
35
+ "store": "nexo.db",
36
+ "table": "email_accounts",
37
+ "table_exists": exists,
38
+ "total": 0,
39
+ "enabled": 0,
40
+ "agent_accounts": 0,
41
+ "operator_accounts": 0,
42
+ "with_credentials": 0,
43
+ "missing_credentials": 0,
44
+ }
45
+ if not exists:
46
+ return out
47
+
48
+ rows = [dict(row) for row in conn.execute("SELECT * FROM email_accounts").fetchall()]
49
+ out["total"] = len(rows)
50
+ out["enabled"] = sum(1 for row in rows if int(row.get("enabled") or 0) == 1)
51
+ out["agent_accounts"] = sum(1 for row in rows if (row.get("account_type") or "").lower() == "agent")
52
+ out["operator_accounts"] = sum(1 for row in rows if (row.get("account_type") or "").lower() == "operator")
53
+ out["with_credentials"] = sum(
54
+ 1
55
+ for row in rows
56
+ if (row.get("credential_service") or "").strip() and (row.get("credential_key") or "").strip()
57
+ )
58
+ out["missing_credentials"] = out["total"] - out["with_credentials"]
59
+ return out
60
+
61
+
62
+ def _event_store_snapshot(email_db_path: str | Path | None = None) -> dict:
63
+ path = Path(email_db_path) if email_db_path else _email_event_db_path()
64
+ out = {
65
+ "store": str(path),
66
+ "db_exists": path.is_file(),
67
+ "emails_table": False,
68
+ "email_events_table": False,
69
+ "total_emails": 0,
70
+ "total_events": 0,
71
+ "pending": 0,
72
+ "processed": 0,
73
+ }
74
+ if not path.is_file():
75
+ return out
76
+
77
+ conn = sqlite3.connect(str(path))
78
+ conn.row_factory = sqlite3.Row
79
+ try:
80
+ out["emails_table"] = _table_exists(conn, "emails")
81
+ out["email_events_table"] = _table_exists(conn, "email_events")
82
+ if out["emails_table"]:
83
+ out["total_emails"] = _count(conn, "SELECT COUNT(*) FROM emails")
84
+ out["pending"] = _count(conn, "SELECT COUNT(*) FROM emails WHERE status='pending'")
85
+ out["processed"] = _count(conn, "SELECT COUNT(*) FROM emails WHERE status='processed'")
86
+ if out["email_events_table"]:
87
+ out["total_events"] = _count(conn, "SELECT COUNT(*) FROM email_events")
88
+ finally:
89
+ conn.close()
90
+ return out
91
+
92
+
93
+ def email_contract_snapshot(email_db_path: str | Path | None = None) -> dict:
94
+ """Return the explicit split between email account config and monitor events."""
95
+ accounts = _accounts_config_snapshot()
96
+ events = _event_store_snapshot(email_db_path)
97
+ return {
98
+ "contract": {
99
+ "layers": ["accounts_config", "event_store"],
100
+ "conflated": False,
101
+ "accounts_config": "nexo.db/email_accounts",
102
+ "event_store": "runtime/nexo-email/nexo-email.db",
103
+ },
104
+ "accounts_config": accounts,
105
+ "event_store": events,
106
+ }
@@ -92,6 +92,7 @@ TECHNICAL_PREFIXES = (
92
92
  "desktop-local-install-",
93
93
  "packaged-code-f06-conflicts-",
94
94
  "legacy-shim-conflicts-",
95
+ "legacy-cognitive-db-",
95
96
  "legacy-personal-brain-db-stubs-",
96
97
  "legacy-root-db-stubs-",
97
98
  "codex-live-sync-",
package/src/server.py CHANGED
@@ -105,7 +105,17 @@ from plugins.episodic_memory import handle_session_diary_read, handle_session_di
105
105
  from plugins.cards import handle_card_match
106
106
  from plugins.skills import handle_skill_match
107
107
  from plugins.workflow import (
108
+ handle_goal_get,
109
+ handle_goal_list,
110
+ handle_goal_open,
111
+ handle_goal_update,
112
+ handle_workflow_compensation,
113
+ handle_workflow_get,
114
+ handle_workflow_handoff,
115
+ handle_workflow_list,
108
116
  handle_workflow_open,
117
+ handle_workflow_replay,
118
+ handle_workflow_resume,
109
119
  handle_workflow_update,
110
120
  )
111
121
  from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
@@ -816,6 +826,115 @@ def nexo_workflow_update(
816
826
  )
817
827
 
818
828
 
829
+ @mcp.tool
830
+ def nexo_goal_open(
831
+ sid: str,
832
+ title: str,
833
+ objective: str = "",
834
+ parent_goal_id: str = "",
835
+ priority: str = "normal",
836
+ next_action: str = "",
837
+ success_signal: str = "",
838
+ owner: str = "",
839
+ shared_state: str = "{}",
840
+ ) -> str:
841
+ """Open a durable goal so objectives survive sessions."""
842
+ return handle_goal_open(
843
+ sid,
844
+ title,
845
+ objective,
846
+ parent_goal_id,
847
+ priority,
848
+ next_action,
849
+ success_signal,
850
+ owner,
851
+ shared_state,
852
+ )
853
+
854
+
855
+ @mcp.tool
856
+ def nexo_goal_update(
857
+ goal_id: str,
858
+ status: str = "",
859
+ title: str = "",
860
+ objective: str = "",
861
+ parent_goal_id: str = "",
862
+ next_action: str = "",
863
+ success_signal: str = "",
864
+ blocker_reason: str = "",
865
+ owner: str = "",
866
+ shared_state: str = "",
867
+ ) -> str:
868
+ """Update a durable goal with active/blocked/completed state."""
869
+ return handle_goal_update(
870
+ goal_id,
871
+ status,
872
+ title,
873
+ objective,
874
+ parent_goal_id,
875
+ next_action,
876
+ success_signal,
877
+ blocker_reason,
878
+ owner,
879
+ shared_state,
880
+ )
881
+
882
+
883
+ @mcp.tool
884
+ def nexo_goal_get(goal_id: str, include_runs: bool = False) -> str:
885
+ """Read one durable goal and optionally include linked workflow runs."""
886
+ return handle_goal_get(goal_id, include_runs)
887
+
888
+
889
+ @mcp.tool
890
+ def nexo_goal_list(status: str = "", include_closed: bool = False, limit: int = 20) -> str:
891
+ """List durable goals."""
892
+ return handle_goal_list(status, include_closed, limit)
893
+
894
+
895
+ @mcp.tool
896
+ def nexo_workflow_get(run_id: str, include_steps: bool = True, checkpoint_limit: int = 8) -> str:
897
+ """Read the full durable workflow state."""
898
+ return handle_workflow_get(run_id, include_steps, checkpoint_limit)
899
+
900
+
901
+ @mcp.tool
902
+ def nexo_workflow_handoff(
903
+ run_id: str,
904
+ actor: str,
905
+ next_action: str = "",
906
+ handoff_note: str = "",
907
+ shared_state: str = "",
908
+ new_owner: str = "",
909
+ ) -> str:
910
+ """Record a durable workflow handoff."""
911
+ return handle_workflow_handoff(run_id, actor, next_action, handoff_note, shared_state, new_owner)
912
+
913
+
914
+ @mcp.tool
915
+ def nexo_workflow_compensation(run_id: str, checkpoint_limit: int = 10) -> str:
916
+ """Return the compensation plan for a partially completed workflow."""
917
+ return handle_workflow_compensation(run_id, checkpoint_limit)
918
+
919
+
920
+ @mcp.tool
921
+ def nexo_workflow_resume(run_id: str) -> str:
922
+ """Summarize the next actionable step for a workflow run."""
923
+ return handle_workflow_resume(run_id)
924
+
925
+
926
+ @mcp.tool
927
+ def nexo_workflow_replay(run_id: str, limit: int = 20) -> str:
928
+ """Replay recent checkpoints for a workflow run."""
929
+ return handle_workflow_replay(run_id, limit)
930
+
931
+
932
+ @mcp.tool
933
+ def nexo_workflow_list(status: str = "", include_closed: bool = False, limit: int = 20) -> str:
934
+ """List durable workflow runs."""
935
+ return handle_workflow_list(status, include_closed, limit)
936
+
937
+
819
938
  @mcp.tool
820
939
  def nexo_status(keyword: str = "") -> str:
821
940
  """List active sessions. Filter by keyword if provided."""
@@ -18,12 +18,73 @@ before persisting. The agent should never mint a BYOK entry on its own.
18
18
 
19
19
  import json
20
20
  import os
21
+ import re
21
22
  from pathlib import Path
22
23
 
23
24
  from db import create_credential, update_credential, delete_credential, get_credential, list_credentials, get_db
24
25
 
25
26
 
26
27
  BYOK_SERVICE = "byok"
28
+ REDACTED_SECRET_NOTE = "[redacted: secret-like note]"
29
+ SECRET_NOTE_PATTERNS = (
30
+ re.compile(r"\b(?:npm|ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}\b"),
31
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
32
+ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
33
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"),
34
+ re.compile(r"\b(?:api[_-]?key|secret|token|password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE),
35
+ re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b", re.IGNORECASE),
36
+ re.compile(r"\b[A-Za-z0-9+/=_-]{40,}\b"),
37
+ )
38
+
39
+
40
+ def credential_note_has_secret(notes: str) -> bool:
41
+ """Detect notes that appear to contain a credential value."""
42
+ clean = (notes or "").strip()
43
+ if not clean:
44
+ return False
45
+ return any(pattern.search(clean) for pattern in SECRET_NOTE_PATTERNS)
46
+
47
+
48
+ def redact_credential_notes(notes: str) -> str:
49
+ """Return notes safe for list/dashboard surfaces."""
50
+ clean = notes or ""
51
+ if credential_note_has_secret(clean):
52
+ return REDACTED_SECRET_NOTE
53
+ return clean
54
+
55
+
56
+ def public_credential_records(service: str = "") -> list[dict]:
57
+ """List credential metadata from DB and BYOK without leaking values."""
58
+ requested = (service or "").strip()
59
+ records: list[dict] = []
60
+
61
+ if requested != BYOK_SERVICE:
62
+ for row in list_credentials(requested if requested else None):
63
+ records.append(
64
+ {
65
+ "service": row.get("service", ""),
66
+ "key": row.get("key", ""),
67
+ "notes": redact_credential_notes(row.get("notes") or ""),
68
+ "created_at": row.get("created_at", ""),
69
+ "updated_at": row.get("updated_at", ""),
70
+ "backend": "db",
71
+ }
72
+ )
73
+
74
+ if requested in ("", BYOK_SERVICE):
75
+ for row in _byok_get(""):
76
+ records.append(
77
+ {
78
+ "service": row.get("service", BYOK_SERVICE),
79
+ "key": row.get("key", ""),
80
+ "notes": redact_credential_notes(row.get("notes") or ""),
81
+ "created_at": "",
82
+ "updated_at": "",
83
+ "backend": "byok_local",
84
+ }
85
+ )
86
+
87
+ return records
27
88
 
28
89
 
29
90
  def _credential_exists(service: str, key: str) -> bool:
@@ -193,6 +254,11 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
193
254
  "connect the provider there (the UI validates the key with the "
194
255
  "provider before saving)."
195
256
  )
257
+ if credential_note_has_secret(notes):
258
+ return (
259
+ "ERROR: Credential notes look like they contain a secret. "
260
+ "Put the secret in value, and keep notes operational only."
261
+ )
196
262
  # ── R02 (Fase 2 Protocol Enforcer): reject exact (service, key) duplicates ──
197
263
  force_flag = str(force or "").strip().lower() in {"1", "true", "yes", "on"}
198
264
  if not force_flag and _credential_exists(service, key):
@@ -223,6 +289,11 @@ def handle_credential_update(service: str, key: str, value: str = '', notes: str
223
289
  "ERROR: BYOK credentials are not editable from the agent. "
224
290
  "Ask the user to update the connection in NEXO Desktop > Settings > Connections."
225
291
  )
292
+ if credential_note_has_secret(notes):
293
+ return (
294
+ "ERROR: Credential notes look like they contain a secret. "
295
+ "Put the secret in value, and keep notes operational only."
296
+ )
226
297
  result = update_credential(
227
298
  service,
228
299
  key,
@@ -264,17 +335,14 @@ def handle_credential_list(service: str = '') -> str:
264
335
  Listing without ``service`` only returns DB entries (the historical
265
336
  behaviour). Pass ``service='byok'`` to list the BYOK filesystem store.
266
337
  """
267
- if service == BYOK_SERVICE:
268
- results = _byok_get("")
269
- label = "BYOK"
270
- else:
271
- results = list_credentials(service if service else None)
272
- label = service if service else "ALL"
338
+ results = public_credential_records(service)
339
+ label = service if service else "ALL"
273
340
  if not results:
274
341
  return f"CREDENTIALS {label.upper()}: No entries."
275
342
  lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
276
343
  for r in results:
277
344
  notes = r.get("notes") or ""
278
345
  suffix = f" — {notes}" if notes else ""
279
- lines.append(f" {r['service']}/{r['key']}{suffix}")
346
+ backend = r.get("backend") or "db"
347
+ lines.append(f" {r['service']}/{r['key']} ({backend}){suffix}")
280
348
  return "\n".join(lines)