nexo-brain 7.30.17 → 7.30.19
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 +3 -1
- package/package.json +1 -1
- package/src/product_knowledge/__init__.py +28 -0
- package/src/product_knowledge/catalog.json +358 -0
- package/src/product_knowledge/catalog.py +282 -0
- package/src/scripts/nexo-send-reply.py +182 -7
- package/src/server.py +37 -0
- package/src/system_catalog.py +11 -4
- package/src/tools_product_knowledge.py +59 -0
- package/tool-enforcement-map.json +75 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.19",
|
|
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,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.
|
|
21
|
+
Version `7.30.19` is the current packaged-runtime line. 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.
|
|
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.
|
|
22
24
|
|
|
23
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.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.19",
|
|
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",
|
|
@@ -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
|
+
}
|
|
@@ -96,6 +96,22 @@ RESOLUTION_PATTERNS = (
|
|
|
96
96
|
)
|
|
97
97
|
|
|
98
98
|
_REPLY_EVENT_CONFIDENCE = float(os.environ.get("NEXO_REPLY_EVENT_CONFIDENCE", "0.72"))
|
|
99
|
+
_GENERIC_AGENT_EMAIL_NAMES = {
|
|
100
|
+
"admin",
|
|
101
|
+
"agent",
|
|
102
|
+
"alerts",
|
|
103
|
+
"contact",
|
|
104
|
+
"hello",
|
|
105
|
+
"info",
|
|
106
|
+
"mail",
|
|
107
|
+
"nexo",
|
|
108
|
+
"nexoagent",
|
|
109
|
+
"no-reply",
|
|
110
|
+
"noreply",
|
|
111
|
+
"notifications",
|
|
112
|
+
"reply",
|
|
113
|
+
"support",
|
|
114
|
+
}
|
|
99
115
|
_REPLY_EVENT_LABELS = (
|
|
100
116
|
("The reply acknowledges receipt or says the work starts now", "ack"),
|
|
101
117
|
("The reply makes a future commitment or promises an update later", "commitment"),
|
|
@@ -134,23 +150,182 @@ def normalize_reply_text(text):
|
|
|
134
150
|
return re.sub(r"\s+", " ", (text or "").strip()).strip()
|
|
135
151
|
|
|
136
152
|
|
|
137
|
-
def
|
|
153
|
+
def _clean_identity_value(value) -> str:
|
|
154
|
+
text = str(value or "").strip()
|
|
155
|
+
if not text:
|
|
156
|
+
return ""
|
|
157
|
+
return re.sub(r"\s+", " ", text)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _nested_identity_value(mapping: dict | None, *paths: tuple[str, ...]) -> str:
|
|
161
|
+
if not isinstance(mapping, dict):
|
|
162
|
+
return ""
|
|
163
|
+
for path in paths:
|
|
164
|
+
current = mapping
|
|
165
|
+
for key in path:
|
|
166
|
+
if not isinstance(current, dict):
|
|
167
|
+
current = None
|
|
168
|
+
break
|
|
169
|
+
current = current.get(key)
|
|
170
|
+
value = _clean_identity_value(current)
|
|
171
|
+
if value:
|
|
172
|
+
return value
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _sender_email(config: dict | None) -> str:
|
|
177
|
+
if not isinstance(config, dict):
|
|
178
|
+
return ""
|
|
179
|
+
sender = _clean_identity_value(config.get("email"))
|
|
180
|
+
if sender:
|
|
181
|
+
return sender
|
|
182
|
+
agent_account = config.get("agent_account")
|
|
183
|
+
if isinstance(agent_account, dict):
|
|
184
|
+
return _clean_identity_value(agent_account.get("email"))
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _metadata_identity_value(config: dict | None) -> str:
|
|
189
|
+
if not isinstance(config, dict):
|
|
190
|
+
return ""
|
|
191
|
+
sources = [config]
|
|
192
|
+
agent_account = config.get("agent_account")
|
|
193
|
+
if isinstance(agent_account, dict):
|
|
194
|
+
sources.append(agent_account)
|
|
195
|
+
for source in sources:
|
|
196
|
+
metadata = source.get("metadata")
|
|
197
|
+
value = _nested_identity_value(
|
|
198
|
+
metadata if isinstance(metadata, dict) else None,
|
|
199
|
+
("assistant_name",),
|
|
200
|
+
("agent_name",),
|
|
201
|
+
("display_name",),
|
|
202
|
+
("identity", "assistant_name"),
|
|
203
|
+
("identity", "name"),
|
|
204
|
+
)
|
|
205
|
+
if value:
|
|
206
|
+
return value
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _calibration_assistant_name() -> str:
|
|
211
|
+
try:
|
|
212
|
+
from calibration_runtime import load_runtime_calibration
|
|
213
|
+
|
|
214
|
+
payload = load_runtime_calibration()
|
|
215
|
+
except Exception:
|
|
216
|
+
payload = {}
|
|
217
|
+
return _nested_identity_value(
|
|
218
|
+
payload if isinstance(payload, dict) else None,
|
|
219
|
+
("user", "assistant_name"),
|
|
220
|
+
("assistant_name",),
|
|
221
|
+
("identity", "assistant_name"),
|
|
222
|
+
("identity", "name"),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _profile_assistant_name() -> str:
|
|
227
|
+
try:
|
|
228
|
+
from paths import brain_dir
|
|
229
|
+
|
|
230
|
+
profile_path = brain_dir() / "profile.json"
|
|
231
|
+
payload = json.loads(profile_path.read_text(encoding="utf-8")) if profile_path.is_file() else {}
|
|
232
|
+
except Exception:
|
|
233
|
+
payload = {}
|
|
234
|
+
return _nested_identity_value(
|
|
235
|
+
payload if isinstance(payload, dict) else None,
|
|
236
|
+
("assistant_name",),
|
|
237
|
+
("agent_name",),
|
|
238
|
+
("identity", "assistant_name"),
|
|
239
|
+
("identity", "name"),
|
|
240
|
+
("profile", "assistant_name"),
|
|
241
|
+
("profile", "identity", "assistant_name"),
|
|
242
|
+
("profile", "identity", "name"),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _operator_profile_assistant_name(default: str) -> str:
|
|
138
247
|
try:
|
|
139
248
|
from automation_controls import get_operator_profile
|
|
140
249
|
|
|
141
250
|
profile = get_operator_profile()
|
|
142
251
|
except Exception:
|
|
143
252
|
profile = {}
|
|
144
|
-
value =
|
|
145
|
-
return value
|
|
253
|
+
value = _clean_identity_value((profile or {}).get("assistant_name"))
|
|
254
|
+
return "" if value == default else value
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _assistant_from_sender(config: dict | None) -> str:
|
|
258
|
+
sender = _sender_email(config)
|
|
259
|
+
if "@" not in sender:
|
|
260
|
+
return ""
|
|
261
|
+
local = sender.rsplit("@", 1)[0].strip().strip("<>")
|
|
262
|
+
local = local.split("+", 1)[0].strip()
|
|
263
|
+
if not local or local.lower() in _GENERIC_AGENT_EMAIL_NAMES:
|
|
264
|
+
return ""
|
|
265
|
+
words = [word for word in re.split(r"[^A-Za-z0-9]+", local) if word]
|
|
266
|
+
if not words:
|
|
267
|
+
return ""
|
|
268
|
+
candidate = " ".join(word[:1].upper() + word[1:] for word in words)
|
|
269
|
+
return _clean_identity_value(candidate)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _assistant_display_name(default: str = "Nova", config: dict | None = None) -> str:
|
|
273
|
+
candidates = [
|
|
274
|
+
os.environ.get("NEXO_ASSISTANT_NAME", ""),
|
|
275
|
+
_calibration_assistant_name(),
|
|
276
|
+
_profile_assistant_name(),
|
|
277
|
+
_metadata_identity_value(config),
|
|
278
|
+
_operator_profile_assistant_name(default),
|
|
279
|
+
_assistant_from_sender(config),
|
|
280
|
+
]
|
|
281
|
+
for candidate in candidates:
|
|
282
|
+
value = _clean_identity_value(candidate)
|
|
283
|
+
if value:
|
|
284
|
+
return value
|
|
285
|
+
return default
|
|
146
286
|
|
|
147
287
|
|
|
148
288
|
def _signature_label(config: dict) -> str:
|
|
149
|
-
assistant_name = _assistant_display_name()
|
|
150
|
-
sender =
|
|
289
|
+
assistant_name = _assistant_display_name(config=config)
|
|
290
|
+
sender = _sender_email(config)
|
|
151
291
|
return f"{assistant_name} — {sender}" if sender else assistant_name
|
|
152
292
|
|
|
153
293
|
|
|
294
|
+
def _metadata_signature_value(config: dict | None) -> str:
|
|
295
|
+
if not isinstance(config, dict):
|
|
296
|
+
return ""
|
|
297
|
+
sources = [config]
|
|
298
|
+
agent_account = config.get("agent_account")
|
|
299
|
+
if isinstance(agent_account, dict):
|
|
300
|
+
sources.append(agent_account)
|
|
301
|
+
for source in sources:
|
|
302
|
+
metadata = source.get("metadata")
|
|
303
|
+
signature = _nested_identity_value(metadata if isinstance(metadata, dict) else None, ("signature",))
|
|
304
|
+
if signature:
|
|
305
|
+
return signature
|
|
306
|
+
return ""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _autogenerated_signature_owner(signature: str, sender: str) -> str:
|
|
310
|
+
signature = _clean_identity_value(signature)
|
|
311
|
+
sender = _clean_identity_value(sender)
|
|
312
|
+
if not signature or not sender:
|
|
313
|
+
return ""
|
|
314
|
+
pattern = rf"^(.+?)\s+(?:—|-)\s+{re.escape(sender)}$"
|
|
315
|
+
match = re.match(pattern, signature, flags=re.IGNORECASE)
|
|
316
|
+
return _clean_identity_value(match.group(1)) if match else ""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _presentation_signature(config: dict) -> str:
|
|
320
|
+
fallback = _signature_label(config)
|
|
321
|
+
metadata_signature = _metadata_signature_value(config)
|
|
322
|
+
owner = _autogenerated_signature_owner(metadata_signature, _sender_email(config))
|
|
323
|
+
assistant_name = _assistant_display_name(config=config)
|
|
324
|
+
if owner and assistant_name and owner.casefold() != assistant_name.casefold():
|
|
325
|
+
return fallback
|
|
326
|
+
return signature_from_config(config, fallback=fallback)
|
|
327
|
+
|
|
328
|
+
|
|
154
329
|
def _message_id_domain(config: dict) -> str:
|
|
155
330
|
sender = str((config or {}).get("email") or "").strip()
|
|
156
331
|
if "@" in sender:
|
|
@@ -381,7 +556,7 @@ def build_html_quoted(quote_file, quote_from, quote_date):
|
|
|
381
556
|
|
|
382
557
|
def send_email(config, to, cc, subject, body_text, body_html, in_reply_to, references, attachments=None):
|
|
383
558
|
msg = MIMEMultipart("mixed")
|
|
384
|
-
msg["From"] = formataddr((_assistant_display_name(), config["email"]))
|
|
559
|
+
msg["From"] = formataddr((_assistant_display_name(config=config), config["email"]))
|
|
385
560
|
msg["To"] = to
|
|
386
561
|
if cc:
|
|
387
562
|
msg["Cc"] = cc
|
|
@@ -510,7 +685,7 @@ def main(argv=None):
|
|
|
510
685
|
subject=args.subject,
|
|
511
686
|
body_text=body_text,
|
|
512
687
|
body_html=html_fragment,
|
|
513
|
-
signature=
|
|
688
|
+
signature=_presentation_signature(config),
|
|
514
689
|
include_signature=True,
|
|
515
690
|
)
|
|
516
691
|
body_text = presentation.body_text
|
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).
|
package/src/system_catalog.py
CHANGED
|
@@ -614,13 +614,20 @@ def _artifact_entries() -> list[dict]:
|
|
|
614
614
|
|
|
615
615
|
|
|
616
616
|
def _product_capability_entries() -> list[dict]:
|
|
617
|
-
"""
|
|
617
|
+
"""Product contract map for agent self-discovery.
|
|
618
618
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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",
|