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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/nexo-brain.js +7 -1
- package/bin/nexo-managed-mcp.js +42 -4
- package/package.json +1 -1
- package/src/auto_update.py +7 -0
- package/src/cli.py +69 -0
- package/src/closure_plane.py +389 -8
- package/src/db/_schema.py +44 -2
- package/src/managed_mcp/catalog.py +6 -0
- package/src/managed_mcp/reconcile.py +198 -0
- package/src/product_knowledge/catalog.json +53 -3
- package/src/server.py +64 -2
- package/tool-enforcement-map.json +45 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
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
|
+
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)
|
package/bin/nexo-managed-mcp.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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)
|
package/src/closure_plane.py
CHANGED
|
@@ -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}:{
|
|
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(
|
|
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
|
-
|
|
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
|
|
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=
|
|
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(
|
|
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(
|
|
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 = {
|
|
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-
|
|
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": [
|
|
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(
|
|
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
|
-
|
|
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",
|