nexo-brain 7.30.18 → 7.30.20

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.18",
3
+ "version": "7.30.20",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,9 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.30.18` is the current packaged-runtime line. Patch release over v7.30.17 - managed email replies now resolve the active assistant identity per account, replacing stale generated signatures without touching custom signatures.
21
+ Version `7.30.20` is the current packaged-runtime line. 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.
22
22
 
23
- Previously in `7.30.17`: patch release over v7.30.16 - F0.6 repairs promoted helper imports for personal scripts by adding a core-backed compatibility shim without duplicating the script catalog.
23
+ Previously in `7.30.19`: patch release over v7.30.18 - product capabilities now live in a structured, validated catalog with read-only discovery tools and preserved legacy system-catalog names.
24
24
 
25
25
  Previously in `7.30.16`: patch release over v7.30.14 - Desktop diagnostics can read embedding migration status without warming models, and the coordinated Desktop update path is covered for bundled model verification and obsolete managed model cleanup.
26
26
 
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"];
1349
+ return ["db", "cognitive", "doctor", "local_context", "product_knowledge"];
1350
1350
  }
1351
1351
 
1352
1352
  // Brain contracts — files the NEXO Brain publishes to consumers like
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.18",
3
+ "version": "7.30.20",
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",
@@ -2753,6 +2753,7 @@ def _f06_legacy_shim_map() -> list[tuple[str, Path]]:
2753
2753
  ("hooks", core_root / "hooks"),
2754
2754
  ("rules", core_root / "rules"),
2755
2755
  ("local_context", core_root / "local_context"),
2756
+ ("product_knowledge", core_root / "product_knowledge"),
2756
2757
  ("data", NEXO_HOME / "runtime" / "data"),
2757
2758
  ("logs", NEXO_HOME / "runtime" / "logs"),
2758
2759
  ("operations", NEXO_HOME / "runtime" / "operations"),
@@ -2822,6 +2823,7 @@ def _f06_live_legacy_paths() -> list[Path]:
2822
2823
  "hooks",
2823
2824
  "rules",
2824
2825
  "local_context",
2826
+ "product_knowledge",
2825
2827
  "db",
2826
2828
  "dashboard",
2827
2829
  "skills-core",
@@ -2907,6 +2909,7 @@ def _promote_packaged_runtime_code_to_core() -> None:
2907
2909
  ("doctor", core_root / "doctor"),
2908
2910
  ("dashboard", core_root / "dashboard"),
2909
2911
  ("local_context", core_root / "local_context"),
2912
+ ("product_knowledge", core_root / "product_knowledge"),
2910
2913
  ("skills-core", core_root / "skills"),
2911
2914
  ]
2912
2915
 
@@ -4740,6 +4743,7 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
4740
4743
  "cognitive",
4741
4744
  "dashboard",
4742
4745
  "local_context",
4746
+ "product_knowledge",
4743
4747
  "rules",
4744
4748
  "crons",
4745
4749
  "scripts",
@@ -4819,7 +4823,18 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
4819
4823
  def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
4820
4824
  import shutil
4821
4825
 
4822
- packages = ["db", "cognitive", "doctor", "local_context", "dashboard", "rules", "crons", "hooks", "presets"]
4826
+ packages = [
4827
+ "db",
4828
+ "cognitive",
4829
+ "doctor",
4830
+ "local_context",
4831
+ "product_knowledge",
4832
+ "dashboard",
4833
+ "rules",
4834
+ "crons",
4835
+ "hooks",
4836
+ "presets",
4837
+ ]
4823
4838
  flat_files = _runtime_flat_files(src_dir)
4824
4839
  copied_packages = 0
4825
4840
  copied_files = 0
@@ -1104,6 +1104,7 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
1104
1104
  "cognitive",
1105
1105
  "dashboard",
1106
1106
  "local_context",
1107
+ "product_knowledge",
1107
1108
  "rules",
1108
1109
  "crons",
1109
1110
  "scripts",
@@ -0,0 +1,28 @@
1
+ """Structured NEXO product knowledge.
2
+
3
+ The catalog in this package is intentionally small, schema-checked and
4
+ source-linked. It complements the live system catalog; it is not a replacement
5
+ for live backend state.
6
+ """
7
+
8
+ from .catalog import (
9
+ answer_product_question,
10
+ catalog_entries_for_system_catalog,
11
+ explain_capability,
12
+ find_capabilities,
13
+ list_capabilities,
14
+ load_product_catalog,
15
+ surface_status,
16
+ validate_catalog,
17
+ )
18
+
19
+ __all__ = [
20
+ "answer_product_question",
21
+ "catalog_entries_for_system_catalog",
22
+ "explain_capability",
23
+ "find_capabilities",
24
+ "list_capabilities",
25
+ "load_product_catalog",
26
+ "surface_status",
27
+ "validate_catalog",
28
+ ]
@@ -0,0 +1,358 @@
1
+ {
2
+ "schema_version": 1,
3
+ "updated_at": "2026-06-06",
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
+ "capabilities": [
6
+ {
7
+ "id": "nexo_product_self_knowledge",
8
+ "title": "Product Self-Knowledge",
9
+ "category": "product",
10
+ "layer": "brain",
11
+ "status": "live",
12
+ "summary": "NEXO can answer what its own runtime, tools, scripts, projects and supported workflows are by using structured local catalog sources.",
13
+ "aliases": ["system catalog", "tool explain", "self knowledge", "product map", "capabilities"],
14
+ "source_refs": [
15
+ "nexo/src/system_catalog.py",
16
+ "nexo/src/tools_system_catalog.py",
17
+ "nexo/src/server.py:nexo_system_catalog",
18
+ "nexo/src/server.py:nexo_tool_explain"
19
+ ],
20
+ "surfaces": ["Brain MCP tools", "Desktop conversation bootstrap"],
21
+ "live_state": {
22
+ "source": "local generated system catalog",
23
+ "max_age": "per tool call",
24
+ "fallback": "Answer only from this catalog and source_refs; do not guess unsupported capabilities."
25
+ },
26
+ "actions": {
27
+ "read": ["Search catalog sections", "Explain a tool or product capability"],
28
+ "write": []
29
+ },
30
+ "safety": {
31
+ "data_touched": ["tool descriptors", "local project atlas metadata", "script registry metadata"],
32
+ "data_origin": "local runtime metadata",
33
+ "consent_required": "no for read-only lookup",
34
+ "confirmation_required": "never for read-only lookup",
35
+ "credential_policy": "Do not expose credentials or secret values.",
36
+ "retention": "No new user data is stored by read-only lookup.",
37
+ "audit": "Tool calls are covered by the standard NEXO session/tool logs.",
38
+ "forbidden_actions": ["Invent unsupported product features", "Treat static docs as live backend state"]
39
+ },
40
+ "answer_guidance": {
41
+ "must_say": ["Use the live catalog first when available.", "Mark uncertain or missing capabilities as unknown."],
42
+ "must_not_say": ["Do not claim runtime parity that is not in the catalog."]
43
+ }
44
+ },
45
+ {
46
+ "id": "nexo_desktop_settings",
47
+ "title": "Desktop Preferences And Managed Settings",
48
+ "category": "desktop",
49
+ "layer": "desktop",
50
+ "status": "live",
51
+ "summary": "NEXO Desktop exposes non-technical settings through the Settings UI and a structured preference catalog.",
52
+ "aliases": ["preferences", "settings", "configuracion", "configuración", "settings catalog"],
53
+ "source_refs": [
54
+ "nexo-desktop/lib/settings-schema.js",
55
+ "nexo-desktop/lib/settings-preference-catalog.js",
56
+ "nexo-desktop/lib/app-settings-ipc.js",
57
+ "nexo-desktop/renderer/react/panels/Settings/SettingsShell.jsx"
58
+ ],
59
+ "surfaces": ["Desktop Settings", "settings-catalog IPC", "settings-preference-get IPC", "settings-preference-set IPC"],
60
+ "live_state": {
61
+ "source": "Desktop app settings + Brain calibration/profile files",
62
+ "max_age": "per IPC read",
63
+ "fallback": "Tell the user the setting exists only if present in the generated Desktop manifest."
64
+ },
65
+ "actions": {
66
+ "read": ["List preferences", "Read one preference"],
67
+ "write": ["Set one supported preference through settings-preference-set"]
68
+ },
69
+ "safety": {
70
+ "data_touched": ["Desktop settings", "Brain calibration", "Brain profile"],
71
+ "data_origin": "local user profile and Desktop app state",
72
+ "consent_required": "yes before changing preferences that affect behavior",
73
+ "confirmation_required": "yes for destructive, account, billing, permission or automation changes",
74
+ "credential_policy": "Never ask for passwords in chat; use Desktop connection flows or credential tools.",
75
+ "retention": "Settings persist in local app settings and Brain profile/calibration files.",
76
+ "audit": "Preference changes must pass through settings-save/settings-preference-set and standard logs.",
77
+ "forbidden_actions": ["Apply side effects from raw UI onChange", "Bypass Desktop Settings for normal users"]
78
+ },
79
+ "answer_guidance": {
80
+ "must_say": ["Guide non-technical users through Desktop Settings first."],
81
+ "must_not_say": ["Do not tell users to edit JSON files for normal preference changes."]
82
+ }
83
+ },
84
+ {
85
+ "id": "nexo_local_memory",
86
+ "title": "Local Memory And Local File Index",
87
+ "category": "memory",
88
+ "layer": "brain+desktop",
89
+ "status": "live",
90
+ "summary": "NEXO can index approved local folders for local retrieval while preserving exclusions, retention limits and diagnostics.",
91
+ "aliases": ["local memory", "index", "local index", "memoria local", "files"],
92
+ "source_refs": [
93
+ "nexo/src/server.py:nexo_local_index_status",
94
+ "nexo/src/server.py:nexo_local_index_roots",
95
+ "nexo/src/server.py:nexo_local_index_exclusions",
96
+ "nexo-desktop/renderer/react/panels/Settings/tabs/LocalMemoryTab.jsx"
97
+ ],
98
+ "surfaces": ["Desktop Local Memory settings", "Brain local index MCP tools"],
99
+ "live_state": {
100
+ "source": "local index service status and indexed roots",
101
+ "max_age": "per tool call",
102
+ "fallback": "If status is unavailable, describe only the configured design and ask to inspect diagnostics."
103
+ },
104
+ "actions": {
105
+ "read": ["Read index status", "List roots", "List exclusions"],
106
+ "write": ["Add/remove roots", "Add/remove exclusions", "Clear local index after confirmation"]
107
+ },
108
+ "safety": {
109
+ "data_touched": ["local file metadata", "text snippets from approved roots", "index diagnostics"],
110
+ "data_origin": "operator-approved local folders",
111
+ "consent_required": "yes before adding roots or clearing indexed data",
112
+ "confirmation_required": "yes for clear-index, purge, broad roots and sensitive folders",
113
+ "credential_policy": "Do not index credential files; keep secret patterns excluded.",
114
+ "retention": "Must follow local retention and disk growth limits.",
115
+ "audit": "Root/exclusion changes and purge operations must be logged.",
116
+ "forbidden_actions": ["Index home directory broadly without review", "Ignore exclusion rules", "Claim cloud upload for local-only index"]
117
+ },
118
+ "answer_guidance": {
119
+ "must_say": ["Local memory is local-first and controlled from Desktop settings."],
120
+ "must_not_say": ["Do not imply all user files are indexed by default."]
121
+ }
122
+ },
123
+ {
124
+ "id": "nexo_agent_email",
125
+ "title": "Managed Agent Email",
126
+ "category": "email",
127
+ "layer": "desktop+backend",
128
+ "status": "live",
129
+ "summary": "NEXO can use a managed IMAP/SMTP agent mailbox when the account exists, is configured and the user has enabled email automation.",
130
+ "aliases": ["email", "mailbox", "imap", "smtp", "agent mailbox", "managed mailbox"],
131
+ "source_refs": [
132
+ "nexo/src/system_catalog.py:nexo_email_managed_agent_mailbox",
133
+ "nexo-desktop/lib/email-automation-runtime.js",
134
+ "nexo-desktop/renderer/react/panels/Settings/tabs/EmailTab.jsx",
135
+ "nexo-desktop-web NexoEmailController"
136
+ ],
137
+ "surfaces": ["Desktop Email settings", "Backend NEXO Email API", "email-monitor automation"],
138
+ "live_state": {
139
+ "source": "Desktop settings + backend NEXO Email API + local automation supervisor",
140
+ "max_age": "per lookup",
141
+ "fallback": "If not configured, explain the setup route without inventing credentials."
142
+ },
143
+ "actions": {
144
+ "read": ["Check mailbox configuration", "Check automation readiness"],
145
+ "write": ["Configure managed mailbox", "Send email only with explicit authorization and account readiness"]
146
+ },
147
+ "safety": {
148
+ "data_touched": ["email addresses", "message metadata", "message bodies when processing email"],
149
+ "data_origin": "managed mailbox and local email monitor",
150
+ "consent_required": "yes before enabling monitoring or sending email",
151
+ "confirmation_required": "yes before sending external email or changing account settings",
152
+ "credential_policy": "Never request or reveal mailbox passwords in chat; use managed account connection flows.",
153
+ "retention": "Email processing follows local logs and mailbox retention; avoid storing full message bodies unnecessarily.",
154
+ "audit": "Sent email and monitor actions require verifiable logs.",
155
+ "forbidden_actions": ["Send email without current user authorization", "Use private followups as support tickets", "Expose SMTP/IMAP secrets"]
156
+ },
157
+ "answer_guidance": {
158
+ "must_say": ["Email works only when the mailbox is configured and enabled."],
159
+ "must_not_say": ["Do not promise sending if readiness was not checked."]
160
+ }
161
+ },
162
+ {
163
+ "id": "nexo_product_automations",
164
+ "title": "Product Automations",
165
+ "category": "automation",
166
+ "layer": "brain+desktop",
167
+ "status": "live",
168
+ "summary": "NEXO ships managed automations for email monitoring, followups and morning briefings; Desktop exposes safe controls for product automations.",
169
+ "aliases": ["automations", "email-monitor", "followup-runner", "morning-agent", "morning-digest"],
170
+ "source_refs": [
171
+ "nexo-desktop/renderer/react/panels/Settings/tabs/automationTabHelpers.js",
172
+ "nexo/src/server.py:nexo_automation_supervisor",
173
+ "nexo/src/server.py:nexo_automation_reconcile"
174
+ ],
175
+ "surfaces": ["Desktop Automations settings", "Brain automation supervisor"],
176
+ "live_state": {
177
+ "source": "automation supervisor + Desktop automation catalog",
178
+ "max_age": "per supervisor read",
179
+ "fallback": "Describe packaged automation categories, not individual running status."
180
+ },
181
+ "actions": {
182
+ "read": ["List automations", "Read schedule and health"],
183
+ "write": ["Enable/disable supported automations", "Change supported cadence or extra instructions"]
184
+ },
185
+ "safety": {
186
+ "data_touched": ["automation schedule", "automation logs", "email and followup metadata"],
187
+ "data_origin": "local automation runtime",
188
+ "consent_required": "yes before enabling/disabling or changing cadence",
189
+ "confirmation_required": "yes for changes that send email, notify users or alter recurring work",
190
+ "credential_policy": "Automations must use configured managed credentials; no secrets in chat.",
191
+ "retention": "Automation logs follow runtime log retention.",
192
+ "audit": "Automation changes and runs must be traceable.",
193
+ "forbidden_actions": ["Expose technical RSS/feed setup to normal users for Morning Digest", "Claim an automation is running without checking health"]
194
+ },
195
+ "answer_guidance": {
196
+ "must_say": ["Morning Digest is a managed briefing automation, not a raw feed configuration surface."],
197
+ "must_not_say": ["Do not say there are only three automations if the Desktop manifest reports four product automation identifiers."]
198
+ }
199
+ },
200
+ {
201
+ "id": "nexo_support_tickets_api",
202
+ "title": "Support Tickets",
203
+ "category": "support",
204
+ "layer": "desktop+backend",
205
+ "status": "live",
206
+ "summary": "Product bugs, setup failures and customer support reports belong in the backend support ticket system.",
207
+ "aliases": ["support", "tickets", "bug report", "soporte"],
208
+ "source_refs": [
209
+ "nexo/src/system_catalog.py:nexo_support_tickets_api",
210
+ "nexo/src/server.py:nexo_support_ticket_list",
211
+ "nexo/src/server.py:nexo_support_ticket_create",
212
+ "nexo-desktop/renderer/react/panels/Settings/tabs/SupportTab.jsx",
213
+ "nexo-desktop-web routes/web.php"
214
+ ],
215
+ "surfaces": ["Desktop Support settings", "Brain support-ticket tools", "Backend support API"],
216
+ "live_state": {
217
+ "source": "backend support API",
218
+ "max_age": "per API call",
219
+ "fallback": "Create a local draft only if backend is unavailable; do not treat reminders as real tickets."
220
+ },
221
+ "actions": {
222
+ "read": ["List/read tickets"],
223
+ "write": ["Create ticket", "Reply to ticket", "Close/reopen ticket"]
224
+ },
225
+ "safety": {
226
+ "data_touched": ["support subject", "support messages", "diagnostic attachments when user approves"],
227
+ "data_origin": "user report and Desktop diagnostics",
228
+ "consent_required": "yes before sending diagnostics or personal data to support",
229
+ "confirmation_required": "yes before creating external support tickets with attachments",
230
+ "credential_policy": "Use authenticated Desktop/backend session; do not ask for backend tokens in chat.",
231
+ "retention": "Backend support retention applies; diagnostic bundles must be minimized and redacted.",
232
+ "audit": "Ticket IDs and API results are the evidence of support actions.",
233
+ "forbidden_actions": ["Use private reminders/followups instead of support tickets", "Attach logs without permission"]
234
+ },
235
+ "answer_guidance": {
236
+ "must_say": ["Real product reports should be filed as support tickets."],
237
+ "must_not_say": ["Do not say a ticket was created without ticket ID evidence."]
238
+ }
239
+ },
240
+ {
241
+ "id": "nexo_credits_provider_proxy",
242
+ "title": "NEXO Credits Provider Proxy",
243
+ "category": "credits",
244
+ "layer": "backend+desktop",
245
+ "status": "live",
246
+ "summary": "NEXO Credits routes billable provider calls through backend policy, estimation, reservation and reconciliation.",
247
+ "aliases": ["credits", "provider proxy", "models", "openrouter", "heygen", "billing"],
248
+ "source_refs": [
249
+ "nexo/src/system_catalog.py:nexo_provider_proxy",
250
+ "nexo/src/system_catalog.py:nexo_provider_models",
251
+ "nexo-desktop/renderer/react/panels/Settings/tabs/SubscriptionsTab.jsx",
252
+ "nexo-desktop-web ProviderProxyController",
253
+ "nexo-desktop-web ProviderCatalogService"
254
+ ],
255
+ "surfaces": ["Desktop Subscriptions/Credits", "Backend provider-proxy API"],
256
+ "live_state": {
257
+ "source": "backend provider-proxy platforms/models/estimate endpoints",
258
+ "max_age": "per backend call",
259
+ "fallback": "Mark provider/model support as unknown or planned if not returned by backend."
260
+ },
261
+ "actions": {
262
+ "read": ["List platforms", "List models", "Estimate cost", "Read request status"],
263
+ "write": ["Reserve credits", "Execute provider call after explicit user intent"]
264
+ },
265
+ "safety": {
266
+ "data_touched": ["billing account", "credits balance", "provider request payload", "provider request result"],
267
+ "data_origin": "backend account and provider APIs",
268
+ "consent_required": "yes before spending credits or sending payloads to providers",
269
+ "confirmation_required": "yes before billable provider calls or top-ups",
270
+ "credential_policy": "Provider credentials live in backend credential storage; never reveal them.",
271
+ "retention": "Provider request records and ledger retention follow backend policy.",
272
+ "audit": "Credit reservation, ledger and provider request IDs are required evidence.",
273
+ "forbidden_actions": ["Hardcode prices or balances", "Promise video/model invocation unless backend marks it invokable", "Spend credits silently"]
274
+ },
275
+ "answer_guidance": {
276
+ "must_say": ["Balances, prices and invokable models are live backend facts."],
277
+ "must_not_say": ["Do not infer provider availability from marketing copy."]
278
+ }
279
+ },
280
+ {
281
+ "id": "nexo_managed_cloud_edge",
282
+ "title": "Managed Cloud And Edge",
283
+ "category": "cloud",
284
+ "layer": "backend+desktop",
285
+ "status": "planned_live",
286
+ "summary": "NEXO can list and provision managed cloud/edge resources through NEXO Credits when backend routes and spend controls are available.",
287
+ "aliases": ["nexo cloud", "nexo edge", "cloudflare", "gcloud", "managed infrastructure", "domains"],
288
+ "source_refs": [
289
+ "nexo/src/system_catalog.py:nexo_credits_cloud",
290
+ "nexo/src/system_catalog.py:nexo_edge_cloudflare",
291
+ "nexo-desktop-web NexoCloudController",
292
+ "nexo-desktop-web NexoEdgeController"
293
+ ],
294
+ "surfaces": ["Backend NEXO Cloud API", "Backend NEXO Edge API", "future Desktop managed infrastructure UI"],
295
+ "live_state": {
296
+ "source": "backend cloud/edge APIs",
297
+ "max_age": "per backend call",
298
+ "fallback": "Listing can be described as safe discovery; provisioning remains billable and must not be promised without route confirmation."
299
+ },
300
+ "actions": {
301
+ "read": ["List projects", "List sites", "List deployments", "List edge assets"],
302
+ "write": ["Provision project/site", "Register/check domain", "Create DNS/redirect/job after approval"]
303
+ },
304
+ "safety": {
305
+ "data_touched": ["cloud project metadata", "domain names", "DNS records", "deployment metadata", "billing/credits"],
306
+ "data_origin": "backend managed infrastructure and third-party cloud APIs",
307
+ "consent_required": "yes before provisioning or changing infrastructure",
308
+ "confirmation_required": "yes before billable, public or DNS-changing operations",
309
+ "credential_policy": "Cloud provider credentials are backend-managed; do not expose tokens.",
310
+ "retention": "Backend resource and job retention applies.",
311
+ "audit": "Provisioning/job IDs and credit ledger entries are required evidence.",
312
+ "forbidden_actions": ["Register domains without explicit approval", "Change DNS silently", "State a server/route/IP without verification"]
313
+ },
314
+ "answer_guidance": {
315
+ "must_say": ["Listing existing managed infrastructure is safe; creation is billable and approval-gated."],
316
+ "must_not_say": ["Do not claim a domain/server exists without live verification."]
317
+ }
318
+ },
319
+ {
320
+ "id": "nexo_protocol_cards",
321
+ "title": "Protocol Cards",
322
+ "category": "workflow",
323
+ "layer": "backend+brain+desktop",
324
+ "status": "live",
325
+ "summary": "Official workflow cards are served by backend and matched by Brain when available, so product procedures can evolve without hardcoded prompts.",
326
+ "aliases": ["cards", "protocol cards", "workflow cards", "procedures"],
327
+ "source_refs": [
328
+ "nexo/src/server.py:nexo_card_match",
329
+ "nexo-desktop-web CardCatalogService",
330
+ "nexo-desktop/prompts/system/conversation-bootstrap.md"
331
+ ],
332
+ "surfaces": ["Brain nexo_card_match", "Backend cards catalog", "Desktop bootstrap"],
333
+ "live_state": {
334
+ "source": "backend cards API",
335
+ "max_age": "per match request",
336
+ "fallback": "If unauthenticated/unavailable, continue with local product docs and say nothing about internal failure unless operationally relevant."
337
+ },
338
+ "actions": {
339
+ "read": ["Match cards", "Fetch catalog", "Fetch card"],
340
+ "write": ["Activate card when backend supports it and user context requires it"]
341
+ },
342
+ "safety": {
343
+ "data_touched": ["workflow metadata", "business type/category", "authenticated backend session"],
344
+ "data_origin": "backend card catalog",
345
+ "consent_required": "no for read-only matching",
346
+ "confirmation_required": "depends on actions inside the matched card",
347
+ "credential_policy": "Use existing authenticated Desktop/backend session; do not expose tokens.",
348
+ "retention": "Backend/API logging policy applies.",
349
+ "audit": "Card match/use is captured by runtime task evidence when relevant.",
350
+ "forbidden_actions": ["Invent official protocols", "Expose internal card fetch failures as user-facing noise"]
351
+ },
352
+ "answer_guidance": {
353
+ "must_say": ["Follow official cards silently when available."],
354
+ "must_not_say": ["Do not present backend-auth failures as product limitations."]
355
+ }
356
+ }
357
+ ]
358
+ }
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ CATALOG_PATH = Path(__file__).with_name("catalog.json")
11
+ LEGACY_SYSTEM_CATALOG_NAMES = {
12
+ "nexo_agent_email": [
13
+ ("nexo_email_managed_agent_mailbox", "Email NEXO Managed Agent Mailbox"),
14
+ ],
15
+ "nexo_credits_provider_proxy": [
16
+ ("nexo_provider_proxy", "NEXO Credits Provider Proxy"),
17
+ ("nexo_provider_models", "Provider Model Discovery"),
18
+ ],
19
+ "nexo_managed_cloud_edge": [
20
+ ("nexo_credits_cloud", "NEXO Credits Managed Cloud"),
21
+ ("nexo_edge_cloudflare", "NEXO Edge Cloudflare"),
22
+ ],
23
+ }
24
+
25
+ REQUIRED_CAPABILITY_FIELDS = {
26
+ "id",
27
+ "title",
28
+ "category",
29
+ "layer",
30
+ "status",
31
+ "summary",
32
+ "aliases",
33
+ "source_refs",
34
+ "surfaces",
35
+ "live_state",
36
+ "actions",
37
+ "safety",
38
+ "answer_guidance",
39
+ }
40
+ REQUIRED_LIVE_STATE_FIELDS = {"source", "max_age", "fallback"}
41
+ REQUIRED_ACTION_FIELDS = {"read", "write"}
42
+ REQUIRED_SAFETY_FIELDS = {
43
+ "data_touched",
44
+ "data_origin",
45
+ "consent_required",
46
+ "confirmation_required",
47
+ "credential_policy",
48
+ "retention",
49
+ "audit",
50
+ "forbidden_actions",
51
+ }
52
+ REQUIRED_GUIDANCE_FIELDS = {"must_say", "must_not_say"}
53
+
54
+
55
+ def _normalize(text: Any) -> str:
56
+ return str(text or "").strip().lower()
57
+
58
+
59
+ def _tokens(text: Any) -> set[str]:
60
+ return {
61
+ token
62
+ for token in re.findall(r"[a-z0-9][a-z0-9._:-]{1,}", _normalize(text))
63
+ if len(token) >= 2
64
+ }
65
+
66
+
67
+ def _haystack(capability: dict[str, Any]) -> str:
68
+ return " ".join(
69
+ [
70
+ str(capability.get("id", "")),
71
+ str(capability.get("title", "")),
72
+ str(capability.get("category", "")),
73
+ str(capability.get("layer", "")),
74
+ str(capability.get("status", "")),
75
+ str(capability.get("summary", "")),
76
+ " ".join(str(item) for item in capability.get("aliases") or []),
77
+ " ".join(str(item) for item in capability.get("source_refs") or []),
78
+ " ".join(str(item) for item in capability.get("surfaces") or []),
79
+ ]
80
+ )
81
+
82
+
83
+ def _score(query: str, capability: dict[str, Any]) -> float:
84
+ query_tokens = _tokens(query)
85
+ if not query_tokens:
86
+ return 1.0
87
+ haystack_tokens = _tokens(_haystack(capability))
88
+ overlap = query_tokens & haystack_tokens
89
+ if not overlap:
90
+ return 0.0
91
+ exact_bonus = 0.25 if _normalize(query) in _normalize(_haystack(capability)) else 0.0
92
+ return len(overlap) / max(1, len(query_tokens)) + exact_bonus
93
+
94
+
95
+ @lru_cache(maxsize=1)
96
+ def load_product_catalog() -> dict[str, Any]:
97
+ payload = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
98
+ errors = validate_catalog(payload)
99
+ if errors:
100
+ raise ValueError("Invalid product knowledge catalog:\n- " + "\n- ".join(errors))
101
+ return payload
102
+
103
+
104
+ def validate_catalog(catalog: dict[str, Any] | None = None) -> list[str]:
105
+ payload = catalog if catalog is not None else json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
106
+ errors: list[str] = []
107
+ if not isinstance(payload, dict):
108
+ return ["catalog must be a JSON object"]
109
+ if payload.get("schema_version") != 1:
110
+ errors.append("schema_version must be 1")
111
+ capabilities = payload.get("capabilities")
112
+ if not isinstance(capabilities, list) or not capabilities:
113
+ errors.append("capabilities must be a non-empty list")
114
+ return errors
115
+ seen: set[str] = set()
116
+ for index, capability in enumerate(capabilities):
117
+ prefix = f"capabilities[{index}]"
118
+ if not isinstance(capability, dict):
119
+ errors.append(f"{prefix} must be an object")
120
+ continue
121
+ missing = sorted(REQUIRED_CAPABILITY_FIELDS - capability.keys())
122
+ if missing:
123
+ errors.append(f"{prefix} missing fields: {', '.join(missing)}")
124
+ cap_id = str(capability.get("id", "")).strip()
125
+ if not re.match(r"^[a-z0-9][a-z0-9_:-]{2,}$", cap_id):
126
+ errors.append(f"{prefix}.id is invalid")
127
+ if cap_id in seen:
128
+ errors.append(f"{prefix}.id duplicates {cap_id}")
129
+ seen.add(cap_id)
130
+ for field in ("aliases", "source_refs", "surfaces"):
131
+ if not isinstance(capability.get(field), list) or not capability.get(field):
132
+ errors.append(f"{prefix}.{field} must be a non-empty list")
133
+ live_state = capability.get("live_state") or {}
134
+ if not isinstance(live_state, dict):
135
+ errors.append(f"{prefix}.live_state must be an object")
136
+ else:
137
+ missing_live = sorted(REQUIRED_LIVE_STATE_FIELDS - live_state.keys())
138
+ if missing_live:
139
+ errors.append(f"{prefix}.live_state missing fields: {', '.join(missing_live)}")
140
+ actions = capability.get("actions") or {}
141
+ if not isinstance(actions, dict):
142
+ errors.append(f"{prefix}.actions must be an object")
143
+ else:
144
+ missing_actions = sorted(REQUIRED_ACTION_FIELDS - actions.keys())
145
+ if missing_actions:
146
+ errors.append(f"{prefix}.actions missing fields: {', '.join(missing_actions)}")
147
+ for action_field in REQUIRED_ACTION_FIELDS:
148
+ if not isinstance(actions.get(action_field), list):
149
+ errors.append(f"{prefix}.actions.{action_field} must be a list")
150
+ safety = capability.get("safety") or {}
151
+ if not isinstance(safety, dict):
152
+ errors.append(f"{prefix}.safety must be an object")
153
+ else:
154
+ missing_safety = sorted(REQUIRED_SAFETY_FIELDS - safety.keys())
155
+ if missing_safety:
156
+ errors.append(f"{prefix}.safety missing fields: {', '.join(missing_safety)}")
157
+ for list_field in ("data_touched", "forbidden_actions"):
158
+ if not isinstance(safety.get(list_field), list) or not safety.get(list_field):
159
+ errors.append(f"{prefix}.safety.{list_field} must be a non-empty list")
160
+ guidance = capability.get("answer_guidance") or {}
161
+ if not isinstance(guidance, dict):
162
+ errors.append(f"{prefix}.answer_guidance must be an object")
163
+ else:
164
+ missing_guidance = sorted(REQUIRED_GUIDANCE_FIELDS - guidance.keys())
165
+ if missing_guidance:
166
+ errors.append(f"{prefix}.answer_guidance missing fields: {', '.join(missing_guidance)}")
167
+ for list_field in REQUIRED_GUIDANCE_FIELDS:
168
+ if not isinstance(guidance.get(list_field), list):
169
+ errors.append(f"{prefix}.answer_guidance.{list_field} must be a list")
170
+ return errors
171
+
172
+
173
+ def list_capabilities() -> list[dict[str, Any]]:
174
+ return list(load_product_catalog().get("capabilities") or [])
175
+
176
+
177
+ def find_capabilities(
178
+ query: str = "",
179
+ *,
180
+ category: str = "",
181
+ status: str = "",
182
+ limit: int = 20,
183
+ ) -> list[dict[str, Any]]:
184
+ category_clean = _normalize(category)
185
+ status_clean = _normalize(status)
186
+ rows: list[tuple[float, dict[str, Any]]] = []
187
+ for capability in list_capabilities():
188
+ if category_clean and _normalize(capability.get("category")) != category_clean:
189
+ continue
190
+ if status_clean and _normalize(capability.get("status")) != status_clean:
191
+ continue
192
+ score = _score(query, capability)
193
+ if query and score <= 0:
194
+ continue
195
+ rows.append((score, capability))
196
+ rows.sort(key=lambda item: (item[0], item[1].get("id", "")), reverse=True)
197
+ return [dict(row) for _, row in rows[: max(1, int(limit or 20))]]
198
+
199
+
200
+ def explain_capability(capability_id: str = "", *, query: str = "") -> dict[str, Any] | None:
201
+ target = _normalize(capability_id)
202
+ if target:
203
+ for capability in list_capabilities():
204
+ keys = [
205
+ capability.get("id"),
206
+ capability.get("title"),
207
+ *(capability.get("aliases") or []),
208
+ ]
209
+ if target in {_normalize(key) for key in keys}:
210
+ return dict(capability)
211
+ matches = find_capabilities(query or capability_id, limit=1)
212
+ return matches[0] if matches else None
213
+
214
+
215
+ def catalog_entries_for_system_catalog() -> list[dict[str, Any]]:
216
+ entries = []
217
+ for capability in list_capabilities():
218
+ base_entry = {
219
+ "kind": "product_capability",
220
+ "name": capability["id"],
221
+ "display_name": capability["title"],
222
+ "category": capability["category"],
223
+ "layer": capability["layer"],
224
+ "status": capability["status"],
225
+ "description": capability["summary"],
226
+ "source": ", ".join(capability.get("source_refs") or []),
227
+ "surfaces": capability.get("surfaces") or [],
228
+ "aliases": capability.get("aliases") or [],
229
+ "live_state": capability.get("live_state") or {},
230
+ "safety": capability.get("safety") or {},
231
+ "answer_guidance": capability.get("answer_guidance") or {},
232
+ }
233
+ entries.append(base_entry)
234
+ for legacy_name, legacy_display in LEGACY_SYSTEM_CATALOG_NAMES.get(capability["id"], []):
235
+ legacy_entry = dict(base_entry)
236
+ legacy_entry["name"] = legacy_name
237
+ legacy_entry["display_name"] = legacy_display
238
+ legacy_entry["canonical_capability_id"] = capability["id"]
239
+ entries.append(legacy_entry)
240
+ return entries
241
+
242
+
243
+ def answer_product_question(question: str, *, locale: str = "es", limit: int = 5) -> str:
244
+ matches = find_capabilities(question, limit=limit)
245
+ if not matches:
246
+ return "No encuentro esa capacidad en el catálogo de producto. La respuesta segura es marcarla como no verificada hasta consultar la fuente viva."
247
+ lines = ["Respuesta basada en el catálogo de producto NEXO:"]
248
+ for capability in matches:
249
+ lines.append(f"- {capability['title']}: {capability['summary']}")
250
+ live_state = capability.get("live_state") or {}
251
+ if live_state.get("source"):
252
+ lines.append(f" Fuente viva: {live_state['source']}.")
253
+ safety = capability.get("safety") or {}
254
+ if safety.get("confirmation_required"):
255
+ lines.append(f" Confirmación: {safety['confirmation_required']}.")
256
+ lines.append("Regla: precios, saldos, proveedores invocables, tickets e infraestructura se verifican en backend o runtime vivo antes de prometerlos.")
257
+ return "\n".join(lines)
258
+
259
+
260
+ def surface_status(surface: str = "", *, limit: int = 50) -> dict[str, Any]:
261
+ needle = _normalize(surface)
262
+ capabilities = []
263
+ for capability in list_capabilities():
264
+ surfaces = capability.get("surfaces") or []
265
+ if needle and not any(needle in _normalize(item) for item in surfaces):
266
+ continue
267
+ capabilities.append(
268
+ {
269
+ "id": capability["id"],
270
+ "title": capability["title"],
271
+ "category": capability["category"],
272
+ "status": capability["status"],
273
+ "surfaces": surfaces,
274
+ "live_state_source": (capability.get("live_state") or {}).get("source", ""),
275
+ }
276
+ )
277
+ return {
278
+ "ok": True,
279
+ "schema_version": load_product_catalog().get("schema_version"),
280
+ "count": len(capabilities[: max(1, int(limit or 50))]),
281
+ "capabilities": capabilities[: max(1, int(limit or 50))],
282
+ }
package/src/server.py CHANGED
@@ -57,6 +57,13 @@ from tools_system_catalog import (
57
57
  handle_system_catalog,
58
58
  handle_tool_explain,
59
59
  )
60
+ from tools_product_knowledge import (
61
+ handle_capability_explain,
62
+ handle_product_answer,
63
+ handle_product_capabilities,
64
+ handle_product_knowledge_validate,
65
+ handle_product_surface_status,
66
+ )
60
67
  from tools_drive import (
61
68
  handle_drive_signals,
62
69
  handle_drive_reinforce,
@@ -1692,6 +1699,36 @@ def nexo_tool_explain(name: str) -> str:
1692
1699
  return handle_tool_explain(name)
1693
1700
 
1694
1701
 
1702
+ @mcp.tool
1703
+ def nexo_product_capabilities(query: str = "", category: str = "", status: str = "", limit: int = 20) -> str:
1704
+ """Search the structured NEXO product capability catalog."""
1705
+ return handle_product_capabilities(query, category, status, limit)
1706
+
1707
+
1708
+ @mcp.tool
1709
+ def nexo_capability_explain(capability_id: str = "", query: str = "", locale: str = "es") -> str:
1710
+ """Explain one NEXO product capability with source and safety context."""
1711
+ return handle_capability_explain(capability_id, query, locale)
1712
+
1713
+
1714
+ @mcp.tool
1715
+ def nexo_product_answer(question: str, locale: str = "es", limit: int = 5) -> str:
1716
+ """Answer a NEXO product question using the structured product catalog."""
1717
+ return handle_product_answer(question, locale, limit)
1718
+
1719
+
1720
+ @mcp.tool
1721
+ def nexo_product_surface_status(surface: str = "", limit: int = 50) -> str:
1722
+ """Show which NEXO product capabilities are exposed by a product surface."""
1723
+ return handle_product_surface_status(surface, limit)
1724
+
1725
+
1726
+ @mcp.tool
1727
+ def nexo_product_knowledge_validate() -> str:
1728
+ """Validate the structured NEXO product knowledge catalog."""
1729
+ return handle_product_knowledge_validate()
1730
+
1731
+
1695
1732
  @mcp.tool
1696
1733
  def nexo_guardian_rule_override(rule_id: str, mode: str = "", ttl: str = "24h") -> str:
1697
1734
  """Temporarily override a Guardian rule's mode (Plan Consolidado 0.17).
@@ -614,13 +614,20 @@ def _artifact_entries() -> list[dict]:
614
614
 
615
615
 
616
616
  def _product_capability_entries() -> list[dict]:
617
- """Static product contract map for agent self-discovery.
617
+ """Product contract map for agent self-discovery.
618
618
 
619
- These entries are intentionally endpoint-level rather than marketing
620
- copy. They let a fresh agent find the real backend surfaces before
621
- guessing unsupported workflows.
619
+ The structured catalog is the primary source. The fallback below keeps
620
+ ``nexo_system_catalog`` alive if the product-knowledge package has a
621
+ syntax error during development.
622
622
  """
623
623
 
624
+ try:
625
+ from product_knowledge import catalog_entries_for_system_catalog
626
+
627
+ return catalog_entries_for_system_catalog()
628
+ except Exception:
629
+ pass
630
+
624
631
  return [
625
632
  {
626
633
  "kind": "product_capability",
@@ -0,0 +1,59 @@
1
+ """MCP handlers for structured NEXO product knowledge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from product_knowledge import (
9
+ answer_product_question,
10
+ explain_capability,
11
+ find_capabilities,
12
+ list_capabilities,
13
+ surface_status,
14
+ validate_catalog,
15
+ )
16
+
17
+
18
+ def _json(payload: Any) -> str:
19
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
20
+
21
+
22
+ def handle_product_capabilities(
23
+ query: str = "",
24
+ category: str = "",
25
+ status: str = "",
26
+ limit: int = 20,
27
+ ) -> str:
28
+ """Return structured product capabilities from the NEXO catalog."""
29
+ capabilities = find_capabilities(query, category=category, status=status, limit=limit)
30
+ return _json({"ok": True, "count": len(capabilities), "capabilities": capabilities})
31
+
32
+
33
+ def handle_capability_explain(capability_id: str = "", query: str = "", locale: str = "es") -> str:
34
+ """Explain one product capability with safety and source context."""
35
+ capability = explain_capability(capability_id, query=query)
36
+ if not capability:
37
+ return _json({"ok": False, "error": "capability-not-found"})
38
+ return _json({"ok": True, "capability": capability})
39
+
40
+
41
+ def handle_product_answer(question: str, locale: str = "es", limit: int = 5) -> str:
42
+ """Answer a NEXO product question using only the structured product catalog."""
43
+ return answer_product_question(question, locale=locale, limit=limit)
44
+
45
+
46
+ def handle_product_surface_status(surface: str = "", limit: int = 50) -> str:
47
+ """Return which product capabilities are exposed by a surface."""
48
+ return _json(surface_status(surface, limit=limit))
49
+
50
+
51
+ def handle_product_knowledge_validate() -> str:
52
+ """Validate the product knowledge catalog."""
53
+ try:
54
+ errors = validate_catalog()
55
+ capability_count = 0 if errors else len(list_capabilities())
56
+ except Exception as exc:
57
+ errors = [str(exc)]
58
+ capability_count = 0
59
+ return _json({"ok": not errors, "errors": errors, "capability_count": capability_count})
@@ -693,6 +693,21 @@
693
693
  },
694
694
  "triggers_after": []
695
695
  },
696
+ "nexo_capability_explain": {
697
+ "description": "Explain one NEXO product capability with source and safety context",
698
+ "category": "product_knowledge",
699
+ "source": "server",
700
+ "requires": [],
701
+ "provides": [
702
+ "product_capability_explanation"
703
+ ],
704
+ "internal_calls": [],
705
+ "enforcement": {
706
+ "level": "none",
707
+ "rules": []
708
+ },
709
+ "triggers_after": []
710
+ },
696
711
  "nexo_change_commit": {
697
712
  "description": "Link change log entry to git commit",
698
713
  "category": "change_log",
@@ -3496,6 +3511,66 @@
3496
3511
  },
3497
3512
  "triggers_after": []
3498
3513
  },
3514
+ "nexo_product_answer": {
3515
+ "description": "Answer a NEXO product question from the structured product catalog",
3516
+ "category": "product_knowledge",
3517
+ "source": "server",
3518
+ "requires": [],
3519
+ "provides": [
3520
+ "product_answer"
3521
+ ],
3522
+ "internal_calls": [],
3523
+ "enforcement": {
3524
+ "level": "none",
3525
+ "rules": []
3526
+ },
3527
+ "triggers_after": []
3528
+ },
3529
+ "nexo_product_capabilities": {
3530
+ "description": "Search the structured NEXO product capability catalog",
3531
+ "category": "product_knowledge",
3532
+ "source": "server",
3533
+ "requires": [],
3534
+ "provides": [
3535
+ "product_capability_catalog"
3536
+ ],
3537
+ "internal_calls": [],
3538
+ "enforcement": {
3539
+ "level": "none",
3540
+ "rules": []
3541
+ },
3542
+ "triggers_after": []
3543
+ },
3544
+ "nexo_product_knowledge_validate": {
3545
+ "description": "Validate the structured NEXO product knowledge catalog",
3546
+ "category": "product_knowledge",
3547
+ "source": "server",
3548
+ "requires": [],
3549
+ "provides": [
3550
+ "product_knowledge_validation"
3551
+ ],
3552
+ "internal_calls": [],
3553
+ "enforcement": {
3554
+ "level": "none",
3555
+ "rules": []
3556
+ },
3557
+ "triggers_after": []
3558
+ },
3559
+ "nexo_product_surface_status": {
3560
+ "description": "Show which NEXO product capabilities are exposed by a product surface",
3561
+ "category": "product_knowledge",
3562
+ "source": "server",
3563
+ "requires": [],
3564
+ "provides": [
3565
+ "product_surface_status"
3566
+ ],
3567
+ "internal_calls": [],
3568
+ "enforcement": {
3569
+ "level": "none",
3570
+ "rules": []
3571
+ },
3572
+ "triggers_after": []
3573
+ },
3499
3574
  "nexo_recall": {
3500
3575
  "description": "Search across ALL NEXO memory",
3501
3576
  "category": "memory",