nexo-brain 7.30.21 → 7.30.22

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.21",
3
+ "version": "7.30.22",
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.30.21` is the current packaged-runtime line. Patch release over v7.30.20 - Brain now ships managed default MCP catalog/lock reconciliation and the Operational Closure Plane, with Desktop v0.43.12 consuming the coordinated runtime contract.
21
+ Version `7.30.22` is the current packaged-runtime line. Patch release over v7.30.21 - Brain now ships managed default MCP catalog/lock reconciliation and the Operational Closure Plane, with Desktop v0.43.14 consuming the coordinated runtime contract.
22
22
 
23
23
  Previously in `7.30.20`: patch release over v7.30.19 - packaged installs now copy the `product_knowledge` package into the installed runtime so `nexo update` can import the new product knowledge tools.
24
24
 
package/bin/nexo-brain.js CHANGED
@@ -1346,7 +1346,7 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
1346
1346
  }
1347
1347
 
1348
1348
  function getCoreRuntimePackages() {
1349
- return ["db", "cognitive", "doctor", "local_context", "product_knowledge"];
1349
+ return ["db", "cognitive", "doctor", "local_context", "managed_mcp", "product_knowledge"];
1350
1350
  }
1351
1351
 
1352
1352
  // Brain contracts — files the NEXO Brain publishes to consumers like
@@ -4306,6 +4306,12 @@ async function runSetup() {
4306
4306
  const runtimeCliPath = path.join(NEXO_HOME, "bin", "nexo");
4307
4307
  fs.writeFileSync(runtimeCliPath, runtimeCli);
4308
4308
  fs.chmodSync(runtimeCliPath, 0o755);
4309
+ const managedMcpSource = path.join(__dirname, "nexo-managed-mcp.js");
4310
+ if (fs.existsSync(managedMcpSource)) {
4311
+ const managedMcpTarget = path.join(NEXO_HOME, "bin", "nexo-managed-mcp");
4312
+ fs.copyFileSync(managedMcpSource, managedMcpTarget);
4313
+ fs.chmodSync(managedMcpTarget, 0o755);
4314
+ }
4309
4315
 
4310
4316
  log("Copying core packages...");
4311
4317
  // Core packages (directories with __init__.py)
@@ -30,6 +30,23 @@ function findEntry(capabilityId) {
30
30
  return null;
31
31
  }
32
32
 
33
+ function disabledList() {
34
+ return String(process.env.NEXO_MANAGED_MCP_DISABLED_CAPABILITIES || "")
35
+ .split(",")
36
+ .map((item) => item.trim())
37
+ .filter(Boolean);
38
+ }
39
+
40
+ function isDisabled(capabilityId) {
41
+ const global = String(process.env.NEXO_MANAGED_MCP_DISABLE || "").trim().toLowerCase();
42
+ if (["1", "true", "yes", "on"].includes(global)) return true;
43
+ return disabledList().includes(capabilityId);
44
+ }
45
+
46
+ function readInstalledState() {
47
+ return readJson(path.join(nexoHome(), "runtime", "managed-mcp", "installed-state.json")) || {};
48
+ }
49
+
33
50
  function providerEnv() {
34
51
  return {
35
52
  PATH: process.env.PATH || "",
@@ -38,8 +55,15 @@ function providerEnv() {
38
55
  };
39
56
  }
40
57
 
41
- function stagedProviderCommand(providerId) {
42
- return path.join(nexoHome(), "runtime", "managed-mcp", "artifacts", providerId, "bin", providerId);
58
+ function stagedProviderCommand(providerId, state) {
59
+ const providers = state && state.providers && typeof state.providers === "object" ? state.providers : {};
60
+ const provider = providers[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : {};
61
+ const explicit = process.platform === "win32"
62
+ ? (provider.executable_win32 || provider.executable)
63
+ : provider.executable;
64
+ if (explicit) return String(explicit);
65
+ const suffix = process.platform === "win32" ? ".cmd" : "";
66
+ return path.join(nexoHome(), "runtime", "managed-mcp", "artifacts", providerId, "bin", `${providerId}${suffix}`);
43
67
  }
44
68
 
45
69
  function spawnProvider(command, args, env) {
@@ -63,6 +87,11 @@ function main() {
63
87
  console.error("usage: nexo-managed-mcp run <capability_id>");
64
88
  process.exit(64);
65
89
  }
90
+ if (isDisabled(capabilityId)) {
91
+ console.error(`NEXO managed MCP capability '${capabilityId}' is disabled by policy.`);
92
+ process.exit(78);
93
+ }
94
+ const state = readInstalledState();
66
95
  const entry = findEntry(capabilityId);
67
96
  const meta = entry && entry.nexo ? entry.nexo : {};
68
97
  const providerId = meta.provider_id || "";
@@ -70,12 +99,21 @@ function main() {
70
99
  console.error(`NEXO managed MCP capability '${capabilityId}' is not installed yet.`);
71
100
  process.exit(69);
72
101
  }
73
- const providerCommand = stagedProviderCommand(providerId);
74
- const env = providerEnv();
102
+ const providerCommand = stagedProviderCommand(providerId, state);
103
+ const env = {
104
+ ...providerEnv(),
105
+ NEXO_MANAGED_MCP_CAPABILITY: capabilityId,
106
+ NEXO_MANAGED_MCP_PROVIDER: providerId,
107
+ NEXO_MANAGED_MCP_RISK: String(meta.risk || ""),
108
+ };
75
109
  if (!fs.existsSync(providerCommand)) {
76
110
  const providerPackage = String(meta.provider_package || "");
77
111
  const providerVersion = String(meta.provider_version || "");
78
112
  const providerBin = String(meta.provider_bin || providerId);
113
+ if (String(process.env.NEXO_MANAGED_MCP_ALLOW_NPX_FALLBACK || "").trim() !== "1") {
114
+ console.error(`NEXO managed MCP provider '${providerId}' is not staged. Run nexo_managed_mcp_status(apply=true) to install managed providers.`);
115
+ process.exit(69);
116
+ }
79
117
  if (!providerPackage || !providerVersion || providerVersion === "0.0.0-managed") {
80
118
  console.error(`NEXO managed MCP provider '${providerId}' is not staged and has no exact locked npm package.`);
81
119
  process.exit(69);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.21",
3
+ "version": "7.30.22",
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",
@@ -4828,6 +4828,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
4828
4828
  "cognitive",
4829
4829
  "doctor",
4830
4830
  "local_context",
4831
+ "managed_mcp",
4831
4832
  "product_knowledge",
4832
4833
  "dashboard",
4833
4834
  "rules",
@@ -4970,6 +4971,12 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
4970
4971
  wrapper = bin_dir / "nexo"
4971
4972
  wrapper.write_text(_runtime_cli_wrapper_text())
4972
4973
  wrapper.chmod(0o755)
4974
+ managed_mcp_src = repo_dir / "bin" / "nexo-managed-mcp.js"
4975
+ if managed_mcp_src.is_file():
4976
+ managed_mcp_target = bin_dir / "nexo-managed-mcp"
4977
+ _remove_runtime_copy_target(managed_mcp_target)
4978
+ shutil.copy2(str(managed_mcp_src), str(managed_mcp_target))
4979
+ managed_mcp_target.chmod(0o755)
4973
4980
 
4974
4981
  return {
4975
4982
  "packages": copied_packages,
package/src/cli.py CHANGED
@@ -170,6 +170,48 @@ def _mcp_status(args) -> int:
170
170
  )
171
171
 
172
172
 
173
+ def _closure(args) -> int:
174
+ from closure_plane import closure_next, closure_snapshot, closure_status, refresh_closure_items
175
+ from db import get_db
176
+ from db._schema import run_migrations
177
+
178
+ conn = get_db()
179
+ run_migrations(conn)
180
+ command = str(getattr(args, "closure_command", "") or "status")
181
+ if command == "status":
182
+ payload = closure_status(
183
+ conn,
184
+ refresh=not bool(getattr(args, "no_refresh", False)),
185
+ limit=max(1, min(int(getattr(args, "limit", 10) or 10), 100)),
186
+ )
187
+ elif command == "next":
188
+ if bool(getattr(args, "refresh", False)):
189
+ refresh_closure_items(conn)
190
+ payload = {
191
+ "ok": True,
192
+ "items": closure_next(
193
+ conn,
194
+ limit=max(1, min(int(getattr(args, "limit", 10) or 10), 100)),
195
+ include_waiting=bool(getattr(args, "include_waiting", False)),
196
+ source=str(getattr(args, "source", "") or ""),
197
+ kind=str(getattr(args, "kind", "") or ""),
198
+ state=str(getattr(args, "state", "") or ""),
199
+ max_risk=getattr(args, "max_risk", 0.0),
200
+ area=str(getattr(args, "area", "") or ""),
201
+ ),
202
+ }
203
+ elif command == "snapshot":
204
+ payload = closure_snapshot(
205
+ conn,
206
+ refresh=not bool(getattr(args, "no_refresh", False)),
207
+ snapshot_date=str(getattr(args, "date", "") or ""),
208
+ limit=max(1, min(int(getattr(args, "limit", 10) or 10), 100)),
209
+ )
210
+ else:
211
+ payload = {"ok": False, "error": "unsupported closure command"}
212
+ return _print_json_or_text(payload, as_json=bool(getattr(args, "json", False)))
213
+
214
+
173
215
  def _mcp_write_message(stdin, payload: dict) -> None:
174
216
  raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
175
217
  stdin.write(raw + b"\n")
@@ -4307,6 +4349,29 @@ def main():
4307
4349
  help="Install Codex automatically when the selected runtime needs it and it is missing.",
4308
4350
  )
4309
4351
 
4352
+ # -- closure --
4353
+ closure_parser = sub.add_parser("closure", help="Operational closure queue")
4354
+ closure_sub = closure_parser.add_subparsers(dest="closure_command")
4355
+ closure_status_p = closure_sub.add_parser("status", help="Show closure queue status")
4356
+ closure_status_p.add_argument("--limit", type=int, default=10, help="Top items to include")
4357
+ closure_status_p.add_argument("--no-refresh", action="store_true", help="Do not refresh adapters before reading")
4358
+ closure_status_p.add_argument("--json", action="store_true", help="JSON output")
4359
+ closure_next_p = closure_sub.add_parser("next", help="Show ranked closure items")
4360
+ closure_next_p.add_argument("--limit", type=int, default=10, help="Maximum items")
4361
+ closure_next_p.add_argument("--include-waiting", action="store_true", help="Include waiting_user/blocked items")
4362
+ closure_next_p.add_argument("--refresh", action="store_true", help="Refresh adapters before reading")
4363
+ closure_next_p.add_argument("--source", default="", help="Filter by source")
4364
+ closure_next_p.add_argument("--kind", default="", help="Filter by kind")
4365
+ closure_next_p.add_argument("--state", default="", help="Filter by state, e.g. ready or waiting_user")
4366
+ closure_next_p.add_argument("--area", default="", help="Filter by owner/source/kind/payload area")
4367
+ closure_next_p.add_argument("--max-risk", type=float, default=0.0, help="Filter by maximum risk_score")
4368
+ closure_next_p.add_argument("--json", action="store_true", help="JSON output")
4369
+ closure_snapshot_p = closure_sub.add_parser("snapshot", help="Write and show the daily closure snapshot")
4370
+ closure_snapshot_p.add_argument("--date", default="", help="Snapshot date YYYY-MM-DD")
4371
+ closure_snapshot_p.add_argument("--limit", type=int, default=10, help="Top items to include")
4372
+ closure_snapshot_p.add_argument("--no-refresh", action="store_true", help="Do not refresh adapters before snapshot")
4373
+ closure_snapshot_p.add_argument("--json", action="store_true", help="JSON output")
4374
+
4310
4375
  # -- preferences --
4311
4376
  preferences_parser = sub.add_parser(
4312
4377
  "preferences",
@@ -4848,6 +4913,10 @@ def main():
4848
4913
  return _clients_sync(args)
4849
4914
  clients_parser.print_help()
4850
4915
  return 0
4916
+ elif args.command == "closure":
4917
+ if not args.closure_command:
4918
+ args.closure_command = "status"
4919
+ return _closure(args)
4851
4920
  elif args.command == "mcp":
4852
4921
  if args.mcp_command == "status":
4853
4922
  return _mcp_status(args)
@@ -11,12 +11,34 @@ import datetime as _dt
11
11
  import hashlib
12
12
  import json
13
13
  import os
14
+ import time as _time
14
15
  from pathlib import Path
15
16
  from typing import Any
16
17
 
17
18
 
18
19
  OPEN_STATES = {"open", "waiting", "verified"}
19
20
  FINAL_STATES = {"closed", "rejected", "stale"}
21
+ STATE_ALIASES = {
22
+ "ready": "open",
23
+ "waiting_user": "waiting",
24
+ "blocked": "waiting",
25
+ "done": "closed",
26
+ }
27
+ READINESS_STATES = {
28
+ "available",
29
+ "missing_tool",
30
+ "missing_credential",
31
+ "needs_user_permission",
32
+ "unsafe",
33
+ "external_blocker",
34
+ "unknown",
35
+ }
36
+ TRIAGE_STATES = OPEN_STATES | FINAL_STATES
37
+
38
+
39
+ def _canonical_state(value: Any) -> str:
40
+ clean = str(value or "").strip().lower()
41
+ return STATE_ALIASES.get(clean, clean)
20
42
 
21
43
 
22
44
  def _now_iso() -> str:
@@ -236,7 +258,7 @@ def _upsert_candidate(conn, item: dict[str, Any]) -> bool:
236
258
 
237
259
 
238
260
  def _record_event(conn, item_id: str, event_type: str, from_state: str, to_state: str, note: str, evidence: str = "") -> None:
239
- event_id = _hash_id("CIE", f"{item_id}:{event_type}:{from_state}:{to_state}:{_now_iso()}:{note}", 24)
261
+ event_id = _hash_id("CIE", f"{item_id}:{event_type}:{from_state}:{to_state}:{_time.time_ns()}:{note}", 24)
240
262
  conn.execute(
241
263
  """
242
264
  INSERT INTO closure_item_events (
@@ -247,6 +269,17 @@ def _record_event(conn, item_id: str, event_type: str, from_state: str, to_state
247
269
  )
248
270
 
249
271
 
272
+ def _readiness_counts(conn) -> dict[str, int]:
273
+ if not _table_exists(conn, "closure_capability_readiness"):
274
+ return {}
275
+ return {
276
+ row["status"]: row["n"]
277
+ for row in conn.execute(
278
+ "SELECT status, COUNT(*) AS n FROM closure_capability_readiness GROUP BY status"
279
+ ).fetchall()
280
+ }
281
+
282
+
250
283
  def _protocol_task_candidates(conn, limit: int) -> list[dict[str, Any]]:
251
284
  if not _table_exists(conn, "protocol_tasks"):
252
285
  return []
@@ -469,6 +502,7 @@ def refresh_closure_items(conn=None, *, limit_per_adapter: int = 250) -> dict[st
469
502
  run_migrations(conn)
470
503
  candidates: list[dict[str, Any]] = []
471
504
  adapter_counts: dict[str, int] = {}
505
+ adapter_errors: dict[str, str] = {}
472
506
  adapters = [
473
507
  ("protocol_tasks", lambda: _protocol_task_candidates(conn, limit_per_adapter)),
474
508
  ("followups", lambda: _followup_candidates(conn, limit_per_adapter)),
@@ -479,8 +513,9 @@ def refresh_closure_items(conn=None, *, limit_per_adapter: int = 250) -> dict[st
479
513
  for name, adapter in adapters:
480
514
  try:
481
515
  produced = adapter()
482
- except Exception:
516
+ except Exception as exc:
483
517
  produced = []
518
+ adapter_errors[name] = f"{type(exc).__name__}: {exc}"
484
519
  adapter_counts[name] = len(produced)
485
520
  candidates.extend(produced)
486
521
 
@@ -493,17 +528,34 @@ def refresh_closure_items(conn=None, *, limit_per_adapter: int = 250) -> dict[st
493
528
  return {
494
529
  "ok": True,
495
530
  "adapters": adapter_counts,
531
+ "adapter_errors": adapter_errors,
496
532
  "observed": len(candidates),
497
533
  "created": created,
498
534
  }
499
535
 
500
536
 
501
- def closure_next(conn=None, *, limit: int = 10, include_waiting: bool = False, source: str = "", kind: str = "") -> list[dict[str, Any]]:
537
+ def closure_next(
538
+ conn=None,
539
+ *,
540
+ limit: int = 10,
541
+ include_waiting: bool = False,
542
+ source: str = "",
543
+ kind: str = "",
544
+ state: str = "",
545
+ max_risk: float | None = None,
546
+ area: str = "",
547
+ ) -> list[dict[str, Any]]:
502
548
  if conn is None:
503
549
  from db import get_db
504
550
 
505
551
  conn = get_db()
506
- states = ("open", "verified", "waiting") if include_waiting else ("open", "verified")
552
+ clean_state = _canonical_state(state)
553
+ if clean_state:
554
+ if clean_state not in OPEN_STATES | FINAL_STATES:
555
+ return []
556
+ states = (clean_state,)
557
+ else:
558
+ states = ("open", "verified", "waiting") if include_waiting else ("open", "verified")
507
559
  clauses = [f"state IN ({','.join('?' for _ in states)})"]
508
560
  params: list[Any] = list(states)
509
561
  if source:
@@ -512,6 +564,24 @@ def closure_next(conn=None, *, limit: int = 10, include_waiting: bool = False, s
512
564
  if kind:
513
565
  clauses.append("kind = ?")
514
566
  params.append(kind)
567
+ if max_risk is not None:
568
+ try:
569
+ risk_limit = float(max_risk)
570
+ except Exception:
571
+ risk_limit = 0.0
572
+ if risk_limit > 0:
573
+ clauses.append("risk_score <= ?")
574
+ params.append(max(0.0, min(risk_limit, 1.0)))
575
+ clean_area = str(area or "").strip()
576
+ if clean_area:
577
+ like_area = f"%{clean_area}%"
578
+ clauses.append(
579
+ "("
580
+ "source_primary = ? OR kind = ? OR owner = ? OR capability_required = ? "
581
+ "OR title LIKE ? OR summary LIKE ? OR source_payload_json LIKE ?"
582
+ ")"
583
+ )
584
+ params.extend([clean_area, clean_area, clean_area, clean_area, like_area, like_area, like_area])
515
585
  params.append(max(1, min(int(limit or 10), 100)))
516
586
  rows = conn.execute(
517
587
  f"""
@@ -547,6 +617,7 @@ def closure_status(conn=None, *, refresh: bool = True, limit: int = 10) -> dict[
547
617
  "schema": "nexo.closure.status.v1",
548
618
  "refreshed": refresh_result,
549
619
  "counts": counts,
620
+ "capability_readiness": _readiness_counts(conn),
550
621
  "open_total": sum(int(counts.get(state, 0)) for state in OPEN_STATES),
551
622
  "by_kind": by_kind,
552
623
  "next": closure_next(conn, limit=limit, include_waiting=True),
@@ -576,8 +647,13 @@ def closure_item_get(item_id: str, conn=None) -> dict[str, Any] | None:
576
647
  "SELECT * FROM closure_item_events WHERE closure_item_id = ? ORDER BY created_at DESC LIMIT 50",
577
648
  (payload["id"],),
578
649
  ).fetchall()
650
+ links = conn.execute(
651
+ "SELECT * FROM closure_item_links WHERE closure_item_id = ? ORDER BY created_at DESC, link_type, link_id",
652
+ (payload["id"],),
653
+ ).fetchall() if _table_exists(conn, "closure_item_links") else []
579
654
  payload["sources"] = [_as_dict(row) for row in sources]
580
655
  payload["events"] = [_as_dict(row) for row in events]
656
+ payload["links"] = [_as_dict(row) for row in links]
581
657
  return payload
582
658
 
583
659
 
@@ -641,12 +717,238 @@ def closure_close_item(item_id: str, *, reason: str = "completed", conn=None) ->
641
717
  return {"ok": True, "id": item["id"], "state": final_state}
642
718
 
643
719
 
644
- def _write_daily_snapshot(conn) -> None:
720
+ def closure_link_item(
721
+ item_id: str,
722
+ *,
723
+ link_type: str,
724
+ link_id: str,
725
+ relation: str = "related",
726
+ conn=None,
727
+ ) -> dict[str, Any]:
728
+ if conn is None:
729
+ from db import get_db
730
+
731
+ conn = get_db()
732
+ item = closure_item_get(item_id, conn)
733
+ if not item:
734
+ return {"ok": False, "error": "closure item not found"}
735
+ clean_type = str(link_type or "").strip()
736
+ clean_id = str(link_id or "").strip()
737
+ clean_relation = str(relation or "related").strip() or "related"
738
+ if not clean_type or not clean_id:
739
+ return {"ok": False, "error": "link_type and link_id are required"}
740
+ now = _now_iso()
741
+ link_pk = _hash_id("CIL", f"{item['id']}:{clean_type}:{clean_id}:{clean_relation}", 24)
742
+ conn.execute(
743
+ """
744
+ INSERT INTO closure_item_links (
745
+ id, closure_item_id, link_type, link_id, relation, created_at
746
+ ) VALUES (?, ?, ?, ?, ?, ?)
747
+ ON CONFLICT(closure_item_id, link_type, link_id, relation) DO UPDATE SET
748
+ created_at = excluded.created_at
749
+ """,
750
+ (link_pk, item["id"], clean_type, clean_id, clean_relation, now),
751
+ )
752
+ _record_event(
753
+ conn,
754
+ item["id"],
755
+ "linked",
756
+ item["state"],
757
+ item["state"],
758
+ f"Linked {clean_type}:{clean_id} as {clean_relation}.",
759
+ )
760
+ conn.commit()
761
+ return {
762
+ "ok": True,
763
+ "id": link_pk,
764
+ "closure_item_id": item["id"],
765
+ "link_type": clean_type,
766
+ "link_id": clean_id,
767
+ "relation": clean_relation,
768
+ }
769
+
770
+
771
+ def closure_set_capability_readiness(
772
+ capability: str,
773
+ *,
774
+ status: str = "unknown",
775
+ reason: str = "",
776
+ evidence: str = "",
777
+ expires_at: str = "",
778
+ conn=None,
779
+ ) -> dict[str, Any]:
780
+ if conn is None:
781
+ from db import get_db
782
+
783
+ conn = get_db()
784
+ clean_capability = str(capability or "").strip()
785
+ clean_status = str(status or "unknown").strip()
786
+ if not clean_capability:
787
+ return {"ok": False, "error": "capability is required"}
788
+ if clean_status not in READINESS_STATES:
789
+ return {"ok": False, "error": f"invalid readiness status: {clean_status}"}
790
+ now = _now_iso()
791
+ row_id = _hash_id("CCR", clean_capability, 20)
792
+ conn.execute(
793
+ """
794
+ INSERT INTO closure_capability_readiness (
795
+ id, capability, status, reason, verified_at, verification_evidence, expires_at
796
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
797
+ ON CONFLICT(capability) DO UPDATE SET
798
+ status = excluded.status,
799
+ reason = excluded.reason,
800
+ verified_at = excluded.verified_at,
801
+ verification_evidence = excluded.verification_evidence,
802
+ expires_at = excluded.expires_at
803
+ """,
804
+ (row_id, clean_capability, clean_status, str(reason or ""), now, str(evidence or ""), str(expires_at or "")),
805
+ )
806
+ conn.commit()
807
+ return {"ok": True, "id": row_id, "capability": clean_capability, "status": clean_status}
808
+
809
+
810
+ def closure_triage_item(
811
+ item_id: str,
812
+ *,
813
+ state: str = "",
814
+ kind: str = "",
815
+ blocker_reason: str = "",
816
+ next_action: str = "",
817
+ evidence_required: str = "",
818
+ owner: str = "",
819
+ capability_required: str = "",
820
+ capability_status: str = "",
821
+ duplicate_of: str = "",
822
+ conn=None,
823
+ ) -> dict[str, Any]:
824
+ if conn is None:
825
+ from db import get_db
826
+
827
+ conn = get_db()
828
+ item = closure_item_get(item_id, conn)
829
+ if not item:
830
+ return {"ok": False, "error": "closure item not found"}
831
+ updates: dict[str, Any] = {}
832
+ requested_state = str(state or "").strip()
833
+ clean_state = _canonical_state(requested_state)
834
+ if clean_state:
835
+ if clean_state not in TRIAGE_STATES:
836
+ return {"ok": False, "error": f"invalid state: {requested_state}"}
837
+ updates["state"] = clean_state
838
+ for column, value in (
839
+ ("kind", kind),
840
+ ("blocker_reason", blocker_reason),
841
+ ("next_action", next_action),
842
+ ("evidence_required", evidence_required),
843
+ ("owner", owner),
844
+ ("capability_required", capability_required),
845
+ ("capability_status", capability_status),
846
+ ):
847
+ clean_value = str(value or "").strip()
848
+ if clean_value:
849
+ updates[column] = clean_value
850
+ clean_duplicate = str(duplicate_of or "").strip()
851
+ if clean_duplicate:
852
+ target = closure_item_get(clean_duplicate, conn)
853
+ if not target:
854
+ return {"ok": False, "error": "duplicate target not found"}
855
+ closure_link_item(item["id"], link_type="closure_item", link_id=target["id"], relation="duplicate_of", conn=conn)
856
+ updates["state"] = "stale"
857
+ updates["close_reason"] = f"duplicate_of:{target['id']}"
858
+ updates["closed_at"] = _now_iso()
859
+ if not updates:
860
+ return {"ok": False, "error": "no triage changes supplied"}
861
+ now = _now_iso()
862
+ updates["updated_at"] = now
863
+ if set(updates) - {"updated_at"}:
864
+ updates["last_progress_at"] = now
865
+ assignments = ", ".join(f"{column} = ?" for column in updates)
866
+ params = list(updates.values()) + [item["id"]]
867
+ conn.execute(f"UPDATE closure_items SET {assignments} WHERE id = ?", params)
868
+ to_state = updates.get("state", item["state"])
869
+ _record_event(
870
+ conn,
871
+ item["id"],
872
+ "triaged",
873
+ item["state"],
874
+ to_state,
875
+ "Closure item triaged: " + ", ".join(sorted(column for column in updates if column != "updated_at")),
876
+ )
877
+ conn.commit()
878
+ result = {"ok": True, "id": item["id"], "state": to_state, "updated": sorted(updates)}
879
+ if requested_state and requested_state != clean_state:
880
+ result["requested_state"] = requested_state
881
+ return result
882
+
883
+
884
+ def closure_snapshot(conn=None, *, refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> dict[str, Any]:
885
+ if conn is None:
886
+ from db import get_db
887
+ from db._schema import run_migrations
888
+
889
+ conn = get_db()
890
+ run_migrations(conn)
891
+ if refresh:
892
+ refresh_closure_items(conn)
893
+ date_key = str(snapshot_date or _today()).strip()[:10]
894
+ if date_key != _today():
895
+ _write_daily_snapshot_for_date(conn, snapshot_date=date_key, limit=limit)
896
+ else:
897
+ _write_daily_snapshot(conn, limit=limit)
898
+ row = conn.execute(
899
+ "SELECT * FROM closure_daily_snapshots WHERE snapshot_date = ?",
900
+ (date_key,),
901
+ ).fetchone()
902
+ payload = _as_dict(row) if row else {}
903
+ if payload.get("top_items_json"):
904
+ try:
905
+ payload["top_items"] = json.loads(payload["top_items_json"])
906
+ except Exception:
907
+ payload["top_items"] = []
908
+ payload["capability_readiness"] = _readiness_counts(conn)
909
+ return {"ok": bool(payload), "snapshot": payload}
910
+
911
+
912
+ def _write_daily_snapshot_for_date(conn, *, snapshot_date: str, limit: int = 10) -> None:
913
+ counts = {
914
+ row["state"]: row["n"]
915
+ for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
916
+ }
917
+ top = closure_next(conn, limit=limit, include_waiting=True)
918
+ conn.execute(
919
+ """
920
+ INSERT OR REPLACE INTO closure_daily_snapshots (
921
+ snapshot_date, total_open, total_verified, total_waiting, total_closed,
922
+ top_items_json, created_at
923
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
924
+ """,
925
+ (
926
+ snapshot_date,
927
+ int(counts.get("open", 0)),
928
+ int(counts.get("verified", 0)),
929
+ int(counts.get("waiting", 0)),
930
+ int(counts.get("closed", 0)),
931
+ _safe_json([
932
+ {
933
+ "id": item.get("id"),
934
+ "title": item.get("title"),
935
+ "priority_score": item.get("priority_score"),
936
+ "state": item.get("state"),
937
+ }
938
+ for item in top
939
+ ]),
940
+ _now_iso(),
941
+ ),
942
+ )
943
+ conn.commit()
944
+
945
+
946
+ def _write_daily_snapshot(conn, *, limit: int = 10) -> None:
645
947
  counts = {
646
948
  row["state"]: row["n"]
647
949
  for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
648
950
  }
649
- top = closure_next(conn, limit=10, include_waiting=True)
951
+ top = closure_next(conn, limit=limit, include_waiting=True)
650
952
  conn.execute(
651
953
  """
652
954
  INSERT OR REPLACE INTO closure_daily_snapshots (
@@ -679,7 +981,15 @@ def handle_closure_status(refresh: bool = True, limit: int = 10) -> str:
679
981
  return json.dumps(closure_status(refresh=refresh, limit=limit), indent=2, ensure_ascii=False)
680
982
 
681
983
 
682
- def handle_closure_next(limit: int = 10, include_waiting: bool = False, source: str = "", kind: str = "") -> str:
984
+ def handle_closure_next(
985
+ limit: int = 10,
986
+ include_waiting: bool = False,
987
+ source: str = "",
988
+ kind: str = "",
989
+ state: str = "",
990
+ max_risk: float | None = None,
991
+ area: str = "",
992
+ ) -> str:
683
993
  from db import get_db
684
994
  from db._schema import run_migrations
685
995
 
@@ -688,7 +998,16 @@ def handle_closure_next(limit: int = 10, include_waiting: bool = False, source:
688
998
  refresh_closure_items(conn)
689
999
  return json.dumps({
690
1000
  "ok": True,
691
- "items": closure_next(conn, limit=limit, include_waiting=include_waiting, source=source, kind=kind),
1001
+ "items": closure_next(
1002
+ conn,
1003
+ limit=limit,
1004
+ include_waiting=include_waiting,
1005
+ source=source,
1006
+ kind=kind,
1007
+ state=state,
1008
+ max_risk=max_risk,
1009
+ area=area,
1010
+ ),
692
1011
  }, indent=2, ensure_ascii=False)
693
1012
 
694
1013
 
@@ -702,6 +1021,68 @@ def handle_closure_item_get(item_id: str) -> str:
702
1021
  return json.dumps({"ok": bool(item), "item": item}, indent=2, ensure_ascii=False)
703
1022
 
704
1023
 
1024
+ def handle_closure_triage(
1025
+ item_id: str,
1026
+ state: str = "",
1027
+ kind: str = "",
1028
+ blocker_reason: str = "",
1029
+ next_action: str = "",
1030
+ evidence_required: str = "",
1031
+ owner: str = "",
1032
+ capability_required: str = "",
1033
+ capability_status: str = "",
1034
+ duplicate_of: str = "",
1035
+ ) -> str:
1036
+ from db import get_db
1037
+ from db._schema import run_migrations
1038
+
1039
+ conn = get_db()
1040
+ run_migrations(conn)
1041
+ return json.dumps(
1042
+ closure_triage_item(
1043
+ item_id,
1044
+ state=state,
1045
+ kind=kind,
1046
+ blocker_reason=blocker_reason,
1047
+ next_action=next_action,
1048
+ evidence_required=evidence_required,
1049
+ owner=owner,
1050
+ capability_required=capability_required,
1051
+ capability_status=capability_status,
1052
+ duplicate_of=duplicate_of,
1053
+ conn=conn,
1054
+ ),
1055
+ indent=2,
1056
+ ensure_ascii=False,
1057
+ )
1058
+
1059
+
1060
+ def handle_closure_link(item_id: str, link_type: str, link_id: str, relation: str = "related") -> str:
1061
+ from db import get_db
1062
+ from db._schema import run_migrations
1063
+
1064
+ conn = get_db()
1065
+ run_migrations(conn)
1066
+ return json.dumps(
1067
+ closure_link_item(item_id, link_type=link_type, link_id=link_id, relation=relation, conn=conn),
1068
+ indent=2,
1069
+ ensure_ascii=False,
1070
+ )
1071
+
1072
+
1073
+ def handle_closure_snapshot(refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> str:
1074
+ from db import get_db
1075
+ from db._schema import run_migrations
1076
+
1077
+ conn = get_db()
1078
+ run_migrations(conn)
1079
+ return json.dumps(
1080
+ closure_snapshot(conn, refresh=refresh, snapshot_date=snapshot_date, limit=limit),
1081
+ indent=2,
1082
+ ensure_ascii=False,
1083
+ )
1084
+
1085
+
705
1086
  def handle_closure_verify(item_id: str, evidence: str) -> str:
706
1087
  from db import get_db
707
1088
  from db._schema import run_migrations
package/src/db/_schema.py CHANGED
@@ -2867,6 +2867,42 @@ def _m78_operational_closure_plane(conn):
2867
2867
  _migrate_add_index(conn, "idx_closure_events_item", "closure_item_events", "closure_item_id, created_at")
2868
2868
 
2869
2869
 
2870
+ def _m79_operational_closure_links_readiness(conn):
2871
+ """Upgrade Closure Plane with links and capability readiness tables."""
2872
+ _m78_operational_closure_plane(conn)
2873
+ conn.execute(
2874
+ """
2875
+ CREATE TABLE IF NOT EXISTS closure_item_links (
2876
+ id TEXT PRIMARY KEY,
2877
+ closure_item_id TEXT NOT NULL,
2878
+ link_type TEXT NOT NULL,
2879
+ link_id TEXT NOT NULL,
2880
+ relation TEXT NOT NULL DEFAULT 'related',
2881
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2882
+ FOREIGN KEY(closure_item_id) REFERENCES closure_items(id) ON DELETE CASCADE,
2883
+ UNIQUE(closure_item_id, link_type, link_id, relation)
2884
+ )
2885
+ """
2886
+ )
2887
+ conn.execute(
2888
+ """
2889
+ CREATE TABLE IF NOT EXISTS closure_capability_readiness (
2890
+ id TEXT PRIMARY KEY,
2891
+ capability TEXT NOT NULL,
2892
+ status TEXT NOT NULL DEFAULT 'unknown',
2893
+ reason TEXT NOT NULL DEFAULT '',
2894
+ verified_at TEXT NOT NULL,
2895
+ verification_evidence TEXT NOT NULL DEFAULT '',
2896
+ expires_at TEXT NOT NULL DEFAULT '',
2897
+ UNIQUE(capability)
2898
+ )
2899
+ """
2900
+ )
2901
+ _migrate_add_index(conn, "idx_closure_links_item", "closure_item_links", "closure_item_id")
2902
+ _migrate_add_index(conn, "idx_closure_links_target", "closure_item_links", "link_type, link_id")
2903
+ _migrate_add_index(conn, "idx_closure_readiness_capability", "closure_capability_readiness", "capability, status")
2904
+
2905
+
2870
2906
  MIGRATIONS = [
2871
2907
  (1, "learnings_columns", _m1_learnings_columns),
2872
2908
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -2946,6 +2982,7 @@ MIGRATIONS = [
2946
2982
  (76, "semantic_layers", _m76_semantic_layers),
2947
2983
  (77, "morning_briefing_presentation", _m77_morning_briefing_presentation),
2948
2984
  (78, "operational_closure_plane", _m78_operational_closure_plane),
2985
+ (79, "operational_closure_links_readiness", _m79_operational_closure_links_readiness),
2949
2986
  ]
2950
2987
 
2951
2988
 
@@ -2967,7 +3004,11 @@ def run_migrations(conn=None):
2967
3004
  """)
2968
3005
  conn.commit()
2969
3006
 
2970
- applied = {r[0] for r in conn.execute("SELECT version FROM schema_migrations").fetchall()}
3007
+ applied = {
3008
+ int(r[0])
3009
+ for r in conn.execute("SELECT version FROM schema_migrations").fetchall()
3010
+ if str(r[0]).strip().isdigit()
3011
+ }
2971
3012
 
2972
3013
  failed = []
2973
3014
  for version, name, fn in MIGRATIONS:
@@ -2975,9 +3016,10 @@ def run_migrations(conn=None):
2975
3016
  try:
2976
3017
  fn(conn)
2977
3018
  conn.execute(
2978
- "INSERT INTO schema_migrations (version, name) VALUES (?, ?)",
3019
+ "INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (?, ?)",
2979
3020
  (version, name)
2980
3021
  )
3022
+ applied.add(version)
2981
3023
  conn.commit()
2982
3024
  except Exception as e:
2983
3025
  conn.rollback()
@@ -189,6 +189,12 @@ def provider_for_capability(
189
189
 
190
190
 
191
191
  def _runner_path(nexo_home: Path, runtime_root: Path | None = None) -> Path:
192
+ runtime_bin = nexo_home / "bin" / "nexo-managed-mcp"
193
+ if runtime_bin.exists():
194
+ return runtime_bin
195
+ runtime_js = nexo_home / "bin" / "nexo-managed-mcp.js"
196
+ if runtime_js.exists():
197
+ return runtime_js
192
198
  runtime_bin = nexo_home / "runtime" / "bin" / "nexo-managed-mcp"
193
199
  if runtime_bin.exists():
194
200
  return runtime_bin
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import shutil
6
+ import subprocess
5
7
  import time
6
8
  from pathlib import Path
7
9
  from typing import Any
@@ -17,6 +19,23 @@ def _state_path(nexo_home: Path) -> Path:
17
19
  return _state_dir(nexo_home) / "installed-state.json"
18
20
 
19
21
 
22
+ def _artifacts_dir(nexo_home: Path) -> Path:
23
+ return _state_dir(nexo_home) / "artifacts"
24
+
25
+
26
+ def _provider_root(nexo_home: Path, provider_id: str) -> Path:
27
+ return _artifacts_dir(nexo_home) / provider_id
28
+
29
+
30
+ def _provider_stage_dir(nexo_home: Path, provider_id: str, version: str) -> Path:
31
+ return _provider_root(nexo_home, provider_id) / version
32
+
33
+
34
+ def _provider_wrapper_path(nexo_home: Path, provider_id: str, *, platform: str | None = None) -> Path:
35
+ suffix = ".cmd" if str(platform or os.name).lower().startswith(("win", "nt")) else ""
36
+ return _provider_root(nexo_home, provider_id) / "bin" / f"{provider_id}{suffix}"
37
+
38
+
20
39
  def _read_state(nexo_home: Path) -> dict[str, Any]:
21
40
  path = _state_path(nexo_home)
22
41
  if not path.is_file():
@@ -37,6 +56,160 @@ def _write_state(nexo_home: Path, state: dict[str, Any]) -> None:
37
56
  tmp.replace(path)
38
57
 
39
58
 
59
+ def _provider_ids_from_desired(desired: dict[str, Any]) -> set[str]:
60
+ provider_ids: set[str] = set()
61
+ for client_entries in desired.values():
62
+ if not isinstance(client_entries, dict):
63
+ continue
64
+ for entry in client_entries.values():
65
+ meta = entry.get("nexo") if isinstance(entry, dict) else {}
66
+ if isinstance(meta, dict) and meta.get("provider_id"):
67
+ provider_ids.add(str(meta["provider_id"]))
68
+ return provider_ids
69
+
70
+
71
+ def _locked_providers(lock: dict[str, Any]) -> dict[str, dict[str, Any]]:
72
+ providers = lock.get("providers") if isinstance(lock.get("providers"), dict) else {}
73
+ return {str(key): value for key, value in providers.items() if isinstance(value, dict)}
74
+
75
+
76
+ def _run_npm_install(stage_dir: Path, package: str, version: str) -> subprocess.CompletedProcess:
77
+ npm = "npm.cmd" if os.name == "nt" else "npm"
78
+ return subprocess.run(
79
+ [
80
+ npm,
81
+ "install",
82
+ "--prefix",
83
+ str(stage_dir),
84
+ "--omit=dev",
85
+ "--no-audit",
86
+ "--no-fund",
87
+ "--package-lock=false",
88
+ f"{package}@{version}",
89
+ ],
90
+ text=True,
91
+ capture_output=True,
92
+ timeout=180,
93
+ )
94
+
95
+
96
+ def _write_provider_wrappers(
97
+ *,
98
+ nexo_home: Path,
99
+ provider_id: str,
100
+ version: str,
101
+ provider_bin: str,
102
+ ) -> dict[str, str]:
103
+ root = _provider_root(nexo_home, provider_id)
104
+ bin_dir = root / "bin"
105
+ bin_dir.mkdir(parents=True, exist_ok=True)
106
+ stage_dir = _provider_stage_dir(nexo_home, provider_id, version)
107
+ unix_target = stage_dir / "node_modules" / ".bin" / provider_bin
108
+ win_target = stage_dir / "node_modules" / ".bin" / f"{provider_bin}.cmd"
109
+ unix_wrapper = bin_dir / provider_id
110
+ unix_wrapper.write_text(f"#!/bin/sh\nexec {json.dumps(str(unix_target))} \"$@\"\n")
111
+ unix_wrapper.chmod(0o755)
112
+ win_wrapper = bin_dir / f"{provider_id}.cmd"
113
+ win_wrapper.write_text(f"@echo off\r\ncall \"{win_target}\" %*\r\n")
114
+ return {
115
+ "unix": str(unix_wrapper),
116
+ "win32": str(win_wrapper),
117
+ "target": str(unix_target),
118
+ "target_win32": str(win_target),
119
+ }
120
+
121
+
122
+ def _stage_provider(
123
+ *,
124
+ nexo_home: Path,
125
+ provider_id: str,
126
+ locked: dict[str, Any],
127
+ npm_runner=_run_npm_install,
128
+ ) -> dict[str, Any]:
129
+ package = str(locked.get("package") or "").strip()
130
+ version = str(locked.get("version") or "").strip()
131
+ provider_bin = str(locked.get("bin") or "").strip()
132
+ if not package or not version or not provider_bin:
133
+ return {"status": "failed", "error": "provider lock is incomplete"}
134
+ stage_dir = _provider_stage_dir(nexo_home, provider_id, version)
135
+ root = _provider_root(nexo_home, provider_id)
136
+ tmp_dir = root / f".stage-{version}-{int(time.time() * 1000)}"
137
+ root.mkdir(parents=True, exist_ok=True)
138
+ if stage_dir.is_dir():
139
+ wrappers = _write_provider_wrappers(
140
+ nexo_home=nexo_home,
141
+ provider_id=provider_id,
142
+ version=version,
143
+ provider_bin=provider_bin,
144
+ )
145
+ return {
146
+ "status": "healthy",
147
+ "version": version,
148
+ "package": package,
149
+ "staged_path": str(stage_dir),
150
+ "executable": wrappers["unix"],
151
+ "executable_win32": wrappers["win32"],
152
+ "reused": True,
153
+ }
154
+ try:
155
+ tmp_dir.mkdir(parents=True, exist_ok=True)
156
+ result = npm_runner(tmp_dir, package, version)
157
+ if getattr(result, "returncode", 1) != 0:
158
+ return {
159
+ "status": "failed",
160
+ "version": version,
161
+ "package": package,
162
+ "error": (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "npm install failed")[-1200:],
163
+ }
164
+ if stage_dir.exists():
165
+ shutil.rmtree(stage_dir)
166
+ tmp_dir.replace(stage_dir)
167
+ wrappers = _write_provider_wrappers(
168
+ nexo_home=nexo_home,
169
+ provider_id=provider_id,
170
+ version=version,
171
+ provider_bin=provider_bin,
172
+ )
173
+ return {
174
+ "status": "healthy",
175
+ "version": version,
176
+ "package": package,
177
+ "staged_path": str(stage_dir),
178
+ "executable": wrappers["unix"],
179
+ "executable_win32": wrappers["win32"],
180
+ "integrity": str(locked.get("integrity") or ""),
181
+ }
182
+ except Exception as exc:
183
+ return {"status": "failed", "version": version, "package": package, "error": str(exc)}
184
+ finally:
185
+ if tmp_dir.exists():
186
+ shutil.rmtree(tmp_dir, ignore_errors=True)
187
+
188
+
189
+ def _provider_health(
190
+ *,
191
+ nexo_home: Path,
192
+ provider_id: str,
193
+ locked: dict[str, Any],
194
+ platform: str | None = None,
195
+ ) -> dict[str, Any]:
196
+ version = str(locked.get("version") or "").strip()
197
+ executable = _provider_wrapper_path(nexo_home, provider_id, platform=platform)
198
+ stage_dir = _provider_stage_dir(nexo_home, provider_id, version) if version else _provider_root(nexo_home, provider_id)
199
+ if not version:
200
+ return {"status": "failed", "reason": "missing_version"}
201
+ if not stage_dir.is_dir():
202
+ return {"status": "unstaged", "version": version, "staged_path": str(stage_dir)}
203
+ if not executable.exists():
204
+ return {"status": "failed", "version": version, "reason": "wrapper_missing", "executable": str(executable)}
205
+ return {
206
+ "status": "healthy",
207
+ "version": version,
208
+ "staged_path": str(stage_dir),
209
+ "executable": str(executable),
210
+ }
211
+
212
+
40
213
  def reconcile_managed_mcp(
41
214
  *,
42
215
  nexo_home: str | os.PathLike[str] | Path,
@@ -44,6 +217,7 @@ def reconcile_managed_mcp(
44
217
  clients: list[str] | tuple[str, ...] | None = None,
45
218
  apply: bool = False,
46
219
  platform: str | None = None,
220
+ npm_runner=_run_npm_install,
47
221
  ) -> dict[str, Any]:
48
222
  nexo_home_path = Path(nexo_home).expanduser()
49
223
  catalog = load_catalog()
@@ -76,6 +250,27 @@ def reconcile_managed_mcp(
76
250
  actions.append({"client": client, "server": name, "action": action})
77
251
  for name in set(old_entries) - set(entries):
78
252
  actions.append({"client": client, "server": name, "action": "disable"})
253
+ locked_by_provider = _locked_providers(lock)
254
+ provider_ids = _provider_ids_from_desired(desired)
255
+ provider_state: dict[str, Any] = {}
256
+ if apply:
257
+ for provider_id in sorted(provider_ids):
258
+ provider_state[provider_id] = _stage_provider(
259
+ nexo_home=nexo_home_path,
260
+ provider_id=provider_id,
261
+ locked=locked_by_provider.get(provider_id) or {},
262
+ npm_runner=npm_runner,
263
+ )
264
+ else:
265
+ for provider_id in sorted(provider_ids):
266
+ provider_state[provider_id] = _provider_health(
267
+ nexo_home=nexo_home_path,
268
+ provider_id=provider_id,
269
+ locked=locked_by_provider.get(provider_id) or {},
270
+ platform=platform,
271
+ )
272
+ if any((state.get("status") if isinstance(state, dict) else "") not in {"healthy"} for state in provider_state.values()):
273
+ actions.append({"client": "runtime", "server": "managed_mcp", "action": "healthcheck"})
79
274
 
80
275
  state = {
81
276
  "schema": "nexo.managed_mcp.state.v1",
@@ -83,6 +278,7 @@ def reconcile_managed_mcp(
83
278
  "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
84
279
  "validation": validation,
85
280
  "desired": desired,
281
+ "providers": provider_state,
86
282
  "last_plan": actions,
87
283
  }
88
284
  if apply:
@@ -94,6 +290,7 @@ def reconcile_managed_mcp(
94
290
  "validation": validation,
95
291
  "actions": actions,
96
292
  "desired_clients": sorted(desired),
293
+ "providers": provider_state,
97
294
  }
98
295
 
99
296
 
@@ -119,4 +316,5 @@ def managed_mcp_status(
119
316
  "validation": plan["validation"],
120
317
  "last_applied_at": state.get("updated_at", ""),
121
318
  "actions": plan["actions"],
319
+ "providers": plan.get("providers", {}),
122
320
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "updated_at": "2026-06-06",
3
+ "updated_at": "2026-06-07",
4
4
  "source_policy": "Brain owns stable product semantics. Desktop manifests describe installed UI surfaces. Backend endpoints own live state, prices, balances, tickets and provisioning.",
5
5
  "capabilities": [
6
6
  {
@@ -210,6 +210,7 @@
210
210
  "nexo/src/server.py:nexo_support_ticket_list",
211
211
  "nexo/src/server.py:nexo_support_ticket_create",
212
212
  "nexo-desktop/renderer/react/panels/Settings/tabs/SupportTab.jsx",
213
+ "nexo-desktop-web SupportTicketController",
213
214
  "nexo-desktop-web routes/web.php"
214
215
  ],
215
216
  "surfaces": ["Desktop Support settings", "Brain support-ticket tools", "Backend support API"],
@@ -249,12 +250,19 @@
249
250
  "nexo/src/system_catalog.py:nexo_provider_proxy",
250
251
  "nexo/src/system_catalog.py:nexo_provider_models",
251
252
  "nexo-desktop/renderer/react/panels/Settings/tabs/SubscriptionsTab.jsx",
253
+ "nexo-desktop-web CreditsController",
254
+ "nexo-desktop-web NexoCreditsController",
252
255
  "nexo-desktop-web ProviderProxyController",
253
256
  "nexo-desktop-web ProviderCatalogService"
254
257
  ],
255
- "surfaces": ["Desktop Subscriptions/Credits", "Backend provider-proxy API"],
258
+ "surfaces": [
259
+ "Desktop Subscriptions/Credits",
260
+ "Backend credits API",
261
+ "Backend provider-proxy API",
262
+ "Admin NEXO Credits credential/readiness panel"
263
+ ],
256
264
  "live_state": {
257
- "source": "backend provider-proxy platforms/models/estimate endpoints",
265
+ "source": "backend credits and provider-proxy platforms/models/estimate endpoints",
258
266
  "max_age": "per backend call",
259
267
  "fallback": "Mark provider/model support as unknown or planned if not returned by backend."
260
268
  },
@@ -277,6 +285,47 @@
277
285
  "must_not_say": ["Do not infer provider availability from marketing copy."]
278
286
  }
279
287
  },
288
+ {
289
+ "id": "nexo_managed_communications_providers",
290
+ "title": "Managed Communications Providers",
291
+ "category": "credits",
292
+ "layer": "backend+desktop",
293
+ "status": "live",
294
+ "summary": "NEXO can use managed voice, SMS, WhatsApp, mass-email and voice-runtime provider surfaces when backend readiness, credits and spend controls allow it.",
295
+ "aliases": ["managed voice", "managed sms", "managed whatsapp", "mass email", "email mass", "voice", "sms", "whatsapp", "provider communications"],
296
+ "source_refs": [
297
+ "nexo-desktop-web NexoVapiController",
298
+ "nexo-desktop-web NexoTwilioController",
299
+ "nexo-desktop-web NexoWazionWhatsappController",
300
+ "nexo-desktop-web NexoEmailMassController",
301
+ "nexo-desktop-web VoiceController",
302
+ "nexo-desktop-web ProviderCostResolverService"
303
+ ],
304
+ "surfaces": ["Backend managed communications APIs", "NEXO Credits managed provider flows", "Desktop provider request flows"],
305
+ "live_state": {
306
+ "source": "backend managed communications provider APIs + credits ledger",
307
+ "max_age": "per backend call",
308
+ "fallback": "Treat provider availability as unknown or not configured if backend route, readiness or cost checks are unavailable."
309
+ },
310
+ "actions": {
311
+ "read": ["List managed resources", "Estimate cost", "Read job or request status"],
312
+ "write": ["Provision phone/assistant/session/domain after approval", "Send message/email or start call after explicit authorization"]
313
+ },
314
+ "safety": {
315
+ "data_touched": ["phone numbers", "email recipients", "message bodies", "call metadata", "provider job payloads", "credits balance"],
316
+ "data_origin": "user-approved communication payloads and backend provider APIs",
317
+ "consent_required": "yes before sending communications, starting calls or provisioning managed provider resources",
318
+ "confirmation_required": "yes before billable provider operations or external communications",
319
+ "credential_policy": "Provider credentials live in backend credential storage; never reveal them.",
320
+ "retention": "Provider request records, suppression lists, message logs and ledger retention follow backend policy.",
321
+ "audit": "Provider job IDs, request IDs and ledger entries are required evidence.",
322
+ "forbidden_actions": ["Expose vendor/provider names as the product promise", "Start calls or send messages/emails without explicit authorization", "Bypass credit estimation or spend limits"]
323
+ },
324
+ "answer_guidance": {
325
+ "must_say": ["Describe user-facing outcomes, not vendor internals, unless the operator asks for implementation detail.", "Spend, readiness and job state are live backend facts."],
326
+ "must_not_say": ["Do not promise a call, SMS, WhatsApp message or email campaign without backend readiness and authorization evidence."]
327
+ }
328
+ },
280
329
  {
281
330
  "id": "nexo_managed_cloud_edge",
282
331
  "title": "Managed Cloud And Edge",
@@ -326,6 +375,7 @@
326
375
  "aliases": ["cards", "protocol cards", "workflow cards", "procedures"],
327
376
  "source_refs": [
328
377
  "nexo/src/server.py:nexo_card_match",
378
+ "nexo-desktop-web CardController",
329
379
  "nexo-desktop-web CardCatalogService",
330
380
  "nexo-desktop/prompts/system/conversation-bootstrap.md"
331
381
  ],
package/src/server.py CHANGED
@@ -1044,7 +1044,15 @@ def nexo_closure_status(refresh: bool = True, limit: int = 10) -> str:
1044
1044
 
1045
1045
 
1046
1046
  @mcp.tool
1047
- def nexo_closure_next(limit: int = 10, include_waiting: bool = False, source: str = "", kind: str = "") -> str:
1047
+ def nexo_closure_next(
1048
+ limit: int = 10,
1049
+ include_waiting: bool = False,
1050
+ source: str = "",
1051
+ kind: str = "",
1052
+ state: str = "",
1053
+ max_risk: float = 0.0,
1054
+ area: str = "",
1055
+ ) -> str:
1048
1056
  """Return the next ranked closure items without executing source actions."""
1049
1057
  from closure_plane import handle_closure_next
1050
1058
 
@@ -1052,7 +1060,11 @@ def nexo_closure_next(limit: int = 10, include_waiting: bool = False, source: st
1052
1060
  clean_limit = max(1, min(int(limit or 10), 100))
1053
1061
  except Exception:
1054
1062
  clean_limit = 10
1055
- return handle_closure_next(clean_limit, bool(include_waiting), source, kind)
1063
+ try:
1064
+ clean_max_risk = float(max_risk or 0.0)
1065
+ except Exception:
1066
+ clean_max_risk = 0.0
1067
+ return handle_closure_next(clean_limit, bool(include_waiting), source, kind, state, clean_max_risk, area)
1056
1068
 
1057
1069
 
1058
1070
  @mcp.tool
@@ -1063,6 +1075,56 @@ def nexo_closure_item_get(item_id: str) -> str:
1063
1075
  return handle_closure_item_get(item_id)
1064
1076
 
1065
1077
 
1078
+ @mcp.tool
1079
+ def nexo_closure_triage(
1080
+ item_id: str,
1081
+ state: str = "",
1082
+ kind: str = "",
1083
+ blocker_reason: str = "",
1084
+ next_action: str = "",
1085
+ evidence_required: str = "",
1086
+ owner: str = "",
1087
+ capability_required: str = "",
1088
+ capability_status: str = "",
1089
+ duplicate_of: str = "",
1090
+ ) -> str:
1091
+ """Triage a closure item without executing its source action."""
1092
+ from closure_plane import handle_closure_triage
1093
+
1094
+ return handle_closure_triage(
1095
+ item_id,
1096
+ state,
1097
+ kind,
1098
+ blocker_reason,
1099
+ next_action,
1100
+ evidence_required,
1101
+ owner,
1102
+ capability_required,
1103
+ capability_status,
1104
+ duplicate_of,
1105
+ )
1106
+
1107
+
1108
+ @mcp.tool
1109
+ def nexo_closure_link(item_id: str, link_type: str, link_id: str, relation: str = "related") -> str:
1110
+ """Link a closure item to a task, workflow, followup, outcome, or learning."""
1111
+ from closure_plane import handle_closure_link
1112
+
1113
+ return handle_closure_link(item_id, link_type, link_id, relation)
1114
+
1115
+
1116
+ @mcp.tool
1117
+ def nexo_closure_snapshot(refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> str:
1118
+ """Write and return an Operational Closure Plane daily snapshot."""
1119
+ from closure_plane import handle_closure_snapshot
1120
+
1121
+ try:
1122
+ clean_limit = max(1, min(int(limit or 10), 100))
1123
+ except Exception:
1124
+ clean_limit = 10
1125
+ return handle_closure_snapshot(bool(refresh), snapshot_date, clean_limit)
1126
+
1127
+
1066
1128
  @mcp.tool
1067
1129
  def nexo_closure_verify(item_id: str, evidence: str) -> str:
1068
1130
  """Record verification evidence for a closure item."""
@@ -2924,6 +2924,51 @@
2924
2924
  },
2925
2925
  "triggers_after": []
2926
2926
  },
2927
+ "nexo_closure_triage": {
2928
+ "description": "Triage a closure item without executing its source action",
2929
+ "category": "closure",
2930
+ "source": "server",
2931
+ "requires": [],
2932
+ "provides": [
2933
+ "closure_triage_result"
2934
+ ],
2935
+ "internal_calls": [],
2936
+ "enforcement": {
2937
+ "level": "none",
2938
+ "rules": []
2939
+ },
2940
+ "triggers_after": []
2941
+ },
2942
+ "nexo_closure_link": {
2943
+ "description": "Link a closure item to a task, workflow, followup, outcome, or learning",
2944
+ "category": "closure",
2945
+ "source": "server",
2946
+ "requires": [],
2947
+ "provides": [
2948
+ "closure_item_link"
2949
+ ],
2950
+ "internal_calls": [],
2951
+ "enforcement": {
2952
+ "level": "none",
2953
+ "rules": []
2954
+ },
2955
+ "triggers_after": []
2956
+ },
2957
+ "nexo_closure_snapshot": {
2958
+ "description": "Write and return an Operational Closure Plane daily snapshot",
2959
+ "category": "closure",
2960
+ "source": "server",
2961
+ "requires": [],
2962
+ "provides": [
2963
+ "closure_snapshot"
2964
+ ],
2965
+ "internal_calls": [],
2966
+ "enforcement": {
2967
+ "level": "none",
2968
+ "rules": []
2969
+ },
2970
+ "triggers_after": []
2971
+ },
2927
2972
  "nexo_closure_verify": {
2928
2973
  "description": "Record verification evidence for a closure item",
2929
2974
  "category": "closure",