nexo-brain 7.30.21 → 7.30.23

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
  "schema_version": 1,
3
- "updated_at": "2026-06-06",
3
+ "updated_at": "2026-06-07",
4
4
  "source_policy": "Brain owns stable product semantics. Desktop manifests describe installed UI surfaces. Backend endpoints own live state, prices, balances, tickets and provisioning.",
5
5
  "capabilities": [
6
6
  {
@@ -210,6 +210,7 @@
210
210
  "nexo/src/server.py:nexo_support_ticket_list",
211
211
  "nexo/src/server.py:nexo_support_ticket_create",
212
212
  "nexo-desktop/renderer/react/panels/Settings/tabs/SupportTab.jsx",
213
+ "nexo-desktop-web SupportTicketController",
213
214
  "nexo-desktop-web routes/web.php"
214
215
  ],
215
216
  "surfaces": ["Desktop Support settings", "Brain support-ticket tools", "Backend support API"],
@@ -249,12 +250,19 @@
249
250
  "nexo/src/system_catalog.py:nexo_provider_proxy",
250
251
  "nexo/src/system_catalog.py:nexo_provider_models",
251
252
  "nexo-desktop/renderer/react/panels/Settings/tabs/SubscriptionsTab.jsx",
253
+ "nexo-desktop-web CreditsController",
254
+ "nexo-desktop-web NexoCreditsController",
252
255
  "nexo-desktop-web ProviderProxyController",
253
256
  "nexo-desktop-web ProviderCatalogService"
254
257
  ],
255
- "surfaces": ["Desktop Subscriptions/Credits", "Backend provider-proxy API"],
258
+ "surfaces": [
259
+ "Desktop Subscriptions/Credits",
260
+ "Backend credits API",
261
+ "Backend provider-proxy API",
262
+ "Admin NEXO Credits credential/readiness panel"
263
+ ],
256
264
  "live_state": {
257
- "source": "backend provider-proxy platforms/models/estimate endpoints",
265
+ "source": "backend credits and provider-proxy platforms/models/estimate endpoints",
258
266
  "max_age": "per backend call",
259
267
  "fallback": "Mark provider/model support as unknown or planned if not returned by backend."
260
268
  },
@@ -277,6 +285,47 @@
277
285
  "must_not_say": ["Do not infer provider availability from marketing copy."]
278
286
  }
279
287
  },
288
+ {
289
+ "id": "nexo_managed_communications_providers",
290
+ "title": "Managed Communications Providers",
291
+ "category": "credits",
292
+ "layer": "backend+desktop",
293
+ "status": "live",
294
+ "summary": "NEXO can use managed voice, SMS, WhatsApp, mass-email and voice-runtime provider surfaces when backend readiness, credits and spend controls allow it.",
295
+ "aliases": ["managed voice", "managed sms", "managed whatsapp", "mass email", "email mass", "voice", "sms", "whatsapp", "provider communications"],
296
+ "source_refs": [
297
+ "nexo-desktop-web NexoVapiController",
298
+ "nexo-desktop-web NexoTwilioController",
299
+ "nexo-desktop-web NexoWazionWhatsappController",
300
+ "nexo-desktop-web NexoEmailMassController",
301
+ "nexo-desktop-web VoiceController",
302
+ "nexo-desktop-web ProviderCostResolverService"
303
+ ],
304
+ "surfaces": ["Backend managed communications APIs", "NEXO Credits managed provider flows", "Desktop provider request flows"],
305
+ "live_state": {
306
+ "source": "backend managed communications provider APIs + credits ledger",
307
+ "max_age": "per backend call",
308
+ "fallback": "Treat provider availability as unknown or not configured if backend route, readiness or cost checks are unavailable."
309
+ },
310
+ "actions": {
311
+ "read": ["List managed resources", "Estimate cost", "Read job or request status"],
312
+ "write": ["Provision phone/assistant/session/domain after approval", "Send message/email or start call after explicit authorization"]
313
+ },
314
+ "safety": {
315
+ "data_touched": ["phone numbers", "email recipients", "message bodies", "call metadata", "provider job payloads", "credits balance"],
316
+ "data_origin": "user-approved communication payloads and backend provider APIs",
317
+ "consent_required": "yes before sending communications, starting calls or provisioning managed provider resources",
318
+ "confirmation_required": "yes before billable provider operations or external communications",
319
+ "credential_policy": "Provider credentials live in backend credential storage; never reveal them.",
320
+ "retention": "Provider request records, suppression lists, message logs and ledger retention follow backend policy.",
321
+ "audit": "Provider job IDs, request IDs and ledger entries are required evidence.",
322
+ "forbidden_actions": ["Expose vendor/provider names as the product promise", "Start calls or send messages/emails without explicit authorization", "Bypass credit estimation or spend limits"]
323
+ },
324
+ "answer_guidance": {
325
+ "must_say": ["Describe user-facing outcomes, not vendor internals, unless the operator asks for implementation detail.", "Spend, readiness and job state are live backend facts."],
326
+ "must_not_say": ["Do not promise a call, SMS, WhatsApp message or email campaign without backend readiness and authorization evidence."]
327
+ }
328
+ },
280
329
  {
281
330
  "id": "nexo_managed_cloud_edge",
282
331
  "title": "Managed Cloud And Edge",
@@ -326,6 +375,7 @@
326
375
  "aliases": ["cards", "protocol cards", "workflow cards", "procedures"],
327
376
  "source_refs": [
328
377
  "nexo/src/server.py:nexo_card_match",
378
+ "nexo-desktop-web CardController",
329
379
  "nexo-desktop-web CardCatalogService",
330
380
  "nexo-desktop/prompts/system/conversation-bootstrap.md"
331
381
  ],
@@ -109,6 +109,8 @@ CREATE INDEX IF NOT EXISTS idx_ee_email ON email_events(email_id);
109
109
  CREATE INDEX IF NOT EXISTS idx_ee_event ON email_events(event);
110
110
  CREATE INDEX IF NOT EXISTS idx_ee_ts ON email_events(timestamp);
111
111
  """
112
+ ACTION_CLOSURE_EVENTS = ("action_done", "resolution")
113
+ SENT_REPLY_EVENTS = ("action_done", "resolution", "replied")
112
114
  EMAIL_LOOP_GUARD_SQL = """
113
115
  CREATE TABLE IF NOT EXISTS email_loop_guards (
114
116
  thread_key TEXT PRIMARY KEY,
@@ -902,20 +904,74 @@ def _has_active_processing_within(conn, email_id, *, hours=ZOMBIE_TIMEOUT_HOURS)
902
904
 
903
905
 
904
906
  def _has_action_done_after(conn, email_id, reference_ts):
907
+ return _has_email_event_after(conn, email_id, ACTION_CLOSURE_EVENTS, reference_ts)
908
+
909
+
910
+ def _has_email_event_after(conn, email_id, events, reference_ts):
911
+ if not email_id or not reference_ts:
912
+ return False
913
+ event_list = tuple(events or ())
914
+ if not event_list:
915
+ return False
916
+ placeholders = ",".join("?" for _ in event_list)
905
917
  row = conn.execute(
906
- """
918
+ f"""
919
+ SELECT 1
920
+ FROM email_events
921
+ WHERE email_id = ?
922
+ AND event IN ({placeholders})
923
+ AND datetime(replace(timestamp, 'T', ' ')) > datetime(replace(?, 'T', ' '))
924
+ LIMIT 1
925
+ """,
926
+ (email_id, *event_list, reference_ts),
927
+ ).fetchone()
928
+ return bool(row)
929
+
930
+
931
+ def _has_sent_reply_event(conn, email_id):
932
+ if not email_id:
933
+ return False
934
+ placeholders = ",".join("?" for _ in SENT_REPLY_EVENTS)
935
+ row = conn.execute(
936
+ f"""
907
937
  SELECT 1
908
938
  FROM email_events
909
939
  WHERE email_id = ?
910
- AND event IN ('action_done', 'resolution')
911
- AND timestamp > ?
940
+ AND event IN ({placeholders})
912
941
  LIMIT 1
913
942
  """,
914
- (email_id, reference_ts),
943
+ (email_id, *SENT_REPLY_EVENTS),
915
944
  ).fetchone()
916
945
  return bool(row)
917
946
 
918
947
 
948
+ def _mark_processing_email_processed(conn, email_id, *, completed_at):
949
+ cols = _email_table_columns(conn)
950
+ updates = [
951
+ "status = 'processed'",
952
+ "started_at = NULL",
953
+ ]
954
+ params = []
955
+ if "completed_at" in cols:
956
+ updates.append(
957
+ "completed_at = CASE WHEN completed_at IS NULL OR trim(completed_at) = '' THEN ? ELSE completed_at END"
958
+ )
959
+ params.append(completed_at)
960
+ if "error" in cols:
961
+ updates.append("error = NULL")
962
+ params.append(email_id)
963
+ cur = conn.execute(
964
+ f"""
965
+ UPDATE emails
966
+ SET {', '.join(updates)}
967
+ WHERE message_id = ?
968
+ AND status = 'processing'
969
+ """,
970
+ tuple(params),
971
+ )
972
+ return cur.rowcount > 0
973
+
974
+
919
975
  def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
920
976
  conn = sqlite3.connect(db_path)
921
977
  conn.row_factory = sqlite3.Row
@@ -998,7 +1054,12 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
998
1054
  """
999
1055
  ).fetchall()
1000
1056
  recovered = []
1057
+ sent_reconciled = []
1001
1058
  for row in stuck_rows:
1059
+ if _has_sent_reply_event(conn, row["message_id"]):
1060
+ if _mark_processing_email_processed(conn, row["message_id"], completed_at=now_label):
1061
+ sent_reconciled.append(row)
1062
+ continue
1002
1063
  conn.execute(
1003
1064
  """
1004
1065
  UPDATE emails
@@ -1053,6 +1114,11 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
1053
1114
  if recovered:
1054
1115
  lines.append("")
1055
1116
  lines.append(f"Auto-recovery applied: {len(recovered)} processing-stuck email(s) were reset to pending.")
1117
+ if sent_reconciled:
1118
+ lines.append("")
1119
+ lines.append(
1120
+ f"Reconciled {len(sent_reconciled)} processing email(s) with already-sent reply events; no re-open applied."
1121
+ )
1056
1122
  total_reconciled = len(live_reconciled) + len(finished_reconciled)
1057
1123
  if total_reconciled:
1058
1124
  lines.append(f"Reconciled {total_reconciled} email(s) with inconsistent lifecycle state.")
@@ -1665,7 +1731,7 @@ def _recover_unreplied_processed(config, hours=24):
1665
1731
  AND NOT EXISTS (
1666
1732
  SELECT 1 FROM email_events ev
1667
1733
  WHERE ev.email_id = e.message_id
1668
- AND ev.event IN ('replied', 'resolution')
1734
+ AND ev.event IN ('replied', 'resolution', 'action_done')
1669
1735
  )
1670
1736
  ORDER BY e.rowid DESC
1671
1737
  LIMIT 20
@@ -445,7 +445,7 @@ def _has_open_action(conn, email_id):
445
445
  return not latest_done or latest_done < latest_open
446
446
 
447
447
 
448
- def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to="", cc="", message_id="", db_path=EMAIL_DB_PATH):
448
+ def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to="", cc="", message_id="", db_path=EMAIL_DB_PATH, classify_override=""):
449
449
  try:
450
450
  conn = sqlite3.connect(db_path)
451
451
  conn.row_factory = sqlite3.Row
@@ -455,7 +455,7 @@ def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to
455
455
  conn.close()
456
456
  return None
457
457
 
458
- event = classify_reply_event(body_text)
458
+ event = (classify_override or "").strip() or classify_reply_event(body_text)
459
459
  detail = normalize_reply_text(body_text)[:200]
460
460
  meta = json.dumps(
461
461
  {
@@ -651,6 +651,18 @@ def build_parser():
651
651
  parser.add_argument("--quote-date", default="")
652
652
  parser.add_argument("--thread-file", default="", help="Full thread history file (all previous messages)")
653
653
  parser.add_argument("--attach", "--attachment", dest="attach", action="append", default=[], help="File to attach (can repeat)")
654
+ parser.add_argument(
655
+ "--classify-as",
656
+ dest="classify_as",
657
+ default="",
658
+ choices=["", "resolution", "ack", "commitment", "replied", "debt_flagged"],
659
+ help=(
660
+ "Force the reply lifecycle event instead of heuristic auto-classification. "
661
+ "Use when the reply is substantive but opens with a short affirmation that the "
662
+ "text heuristic would mis-read as 'ack' (escalation -> debt_flagged). "
663
+ "Empty (default) keeps automatic classification."
664
+ ),
665
+ )
654
666
  return parser
655
667
 
656
668
 
@@ -711,6 +723,7 @@ def main(argv=None):
711
723
  to=args.to,
712
724
  cc=args.cc,
713
725
  message_id=msg_id,
726
+ classify_override=(args.classify_as or "").strip(),
714
727
  )
715
728
  try:
716
729
  record_sent_email(
package/src/server.py CHANGED
@@ -1044,7 +1044,15 @@ def nexo_closure_status(refresh: bool = True, limit: int = 10) -> str:
1044
1044
 
1045
1045
 
1046
1046
  @mcp.tool
1047
- def nexo_closure_next(limit: int = 10, include_waiting: bool = False, source: str = "", kind: str = "") -> str:
1047
+ def nexo_closure_next(
1048
+ limit: int = 10,
1049
+ include_waiting: bool = False,
1050
+ source: str = "",
1051
+ kind: str = "",
1052
+ state: str = "",
1053
+ max_risk: float = 0.0,
1054
+ area: str = "",
1055
+ ) -> str:
1048
1056
  """Return the next ranked closure items without executing source actions."""
1049
1057
  from closure_plane import handle_closure_next
1050
1058
 
@@ -1052,7 +1060,11 @@ def nexo_closure_next(limit: int = 10, include_waiting: bool = False, source: st
1052
1060
  clean_limit = max(1, min(int(limit or 10), 100))
1053
1061
  except Exception:
1054
1062
  clean_limit = 10
1055
- return handle_closure_next(clean_limit, bool(include_waiting), source, kind)
1063
+ try:
1064
+ clean_max_risk = float(max_risk or 0.0)
1065
+ except Exception:
1066
+ clean_max_risk = 0.0
1067
+ return handle_closure_next(clean_limit, bool(include_waiting), source, kind, state, clean_max_risk, area)
1056
1068
 
1057
1069
 
1058
1070
  @mcp.tool
@@ -1063,6 +1075,56 @@ def nexo_closure_item_get(item_id: str) -> str:
1063
1075
  return handle_closure_item_get(item_id)
1064
1076
 
1065
1077
 
1078
+ @mcp.tool
1079
+ def nexo_closure_triage(
1080
+ item_id: str,
1081
+ state: str = "",
1082
+ kind: str = "",
1083
+ blocker_reason: str = "",
1084
+ next_action: str = "",
1085
+ evidence_required: str = "",
1086
+ owner: str = "",
1087
+ capability_required: str = "",
1088
+ capability_status: str = "",
1089
+ duplicate_of: str = "",
1090
+ ) -> str:
1091
+ """Triage a closure item without executing its source action."""
1092
+ from closure_plane import handle_closure_triage
1093
+
1094
+ return handle_closure_triage(
1095
+ item_id,
1096
+ state,
1097
+ kind,
1098
+ blocker_reason,
1099
+ next_action,
1100
+ evidence_required,
1101
+ owner,
1102
+ capability_required,
1103
+ capability_status,
1104
+ duplicate_of,
1105
+ )
1106
+
1107
+
1108
+ @mcp.tool
1109
+ def nexo_closure_link(item_id: str, link_type: str, link_id: str, relation: str = "related") -> str:
1110
+ """Link a closure item to a task, workflow, followup, outcome, or learning."""
1111
+ from closure_plane import handle_closure_link
1112
+
1113
+ return handle_closure_link(item_id, link_type, link_id, relation)
1114
+
1115
+
1116
+ @mcp.tool
1117
+ def nexo_closure_snapshot(refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> str:
1118
+ """Write and return an Operational Closure Plane daily snapshot."""
1119
+ from closure_plane import handle_closure_snapshot
1120
+
1121
+ try:
1122
+ clean_limit = max(1, min(int(limit or 10), 100))
1123
+ except Exception:
1124
+ clean_limit = 10
1125
+ return handle_closure_snapshot(bool(refresh), snapshot_date, clean_limit)
1126
+
1127
+
1066
1128
  @mcp.tool
1067
1129
  def nexo_closure_verify(item_id: str, evidence: str) -> str:
1068
1130
  """Record verification evidence for a closure item."""
@@ -1079,6 +1141,64 @@ def nexo_closure_close(item_id: str, reason: str = "completed") -> str:
1079
1141
  return handle_closure_close(item_id, reason)
1080
1142
 
1081
1143
 
1144
+ @mcp.tool
1145
+ def nexo_opportunity_refresh(
1146
+ dry_run: bool = True,
1147
+ sources: str = "",
1148
+ limit_per_source: int = 250,
1149
+ write_report: bool = False,
1150
+ ) -> str:
1151
+ """Generate Opportunity Orchestrator candidates from existing evidence."""
1152
+ from opportunity_orchestrator import handle_opportunity_refresh
1153
+
1154
+ try:
1155
+ clean_limit = max(1, min(int(limit_per_source or 250), 500))
1156
+ except Exception:
1157
+ clean_limit = 250
1158
+ return handle_opportunity_refresh(bool(dry_run), sources, clean_limit, bool(write_report))
1159
+
1160
+
1161
+ @mcp.tool
1162
+ def nexo_opportunity_queue(
1163
+ surface: str = "home",
1164
+ limit: int = 3,
1165
+ refresh: bool = False,
1166
+ include_snoozed: bool = False,
1167
+ ) -> str:
1168
+ """Return at most three evidence-backed proposals for a user surface."""
1169
+ from opportunity_orchestrator import handle_opportunity_queue
1170
+
1171
+ try:
1172
+ clean_limit = max(0, min(int(limit or 3), 3))
1173
+ except Exception:
1174
+ clean_limit = 3
1175
+ return handle_opportunity_queue(surface, clean_limit, bool(refresh), bool(include_snoozed))
1176
+
1177
+
1178
+ @mcp.tool
1179
+ def nexo_opportunity_get(opportunity_id: str, include_evidence: bool = True) -> str:
1180
+ """Return one opportunity with evidence and read-only preparations."""
1181
+ from opportunity_orchestrator import handle_opportunity_get
1182
+
1183
+ return handle_opportunity_get(opportunity_id, bool(include_evidence))
1184
+
1185
+
1186
+ @mcp.tool
1187
+ def nexo_opportunity_feedback(proposal_id: str, feedback: str, note: str = "", snooze_until: str = "") -> str:
1188
+ """Record proposal feedback and apply suppression/snooze when requested."""
1189
+ from opportunity_orchestrator import handle_opportunity_feedback
1190
+
1191
+ return handle_opportunity_feedback(proposal_id, feedback, note, snooze_until)
1192
+
1193
+
1194
+ @mcp.tool
1195
+ def nexo_opportunity_suppress(scope_type: str, scope_key: str, reason: str = "", expires_at: str = "") -> str:
1196
+ """Suppress repeated opportunity suggestions by scope."""
1197
+ from opportunity_orchestrator import handle_opportunity_suppress
1198
+
1199
+ return handle_opportunity_suppress(scope_type, scope_key, reason, expires_at)
1200
+
1201
+
1082
1202
  @mcp.tool
1083
1203
  def nexo_local_index_status() -> str:
1084
1204
  """Return local memory index status for Desktop settings and support diagnostics."""
@@ -40,6 +40,9 @@ You MUST register read-side lifecycle events, not send-side events:
40
40
  - When you open/analyze a new email seriously, add an `opened` event for that `message_id`.
41
41
  - When you change `emails.status` to `processing`, also add a `processing` event.
42
42
  - Do NOT register `ack` / `commitment` / `resolution` manually when replying: `nexo-send-reply.py` already does that.
43
+ - When the reply closes or answers a real actionable instruction, pass `--classify-as resolution`.
44
+ This is mandatory when the body starts with a short affirmation (`sí`, `de acuerdo`, `perfecto`, etc.) but then contains substantive instructions or completed work.
45
+ - Use `--classify-as ack` only for a pure receipt/started-working acknowledgement, and `--classify-as commitment` only for a real future commitment.
43
46
  - Use sqlite3 or local python3+sqlite3; tracking is best-effort, append-only, and never deletes historical entries.
44
47
 
45
48
  == WHEN THERE IS DEBT BUT NO UNREAD EMAIL ==
@@ -169,7 +172,9 @@ Mandatory steps before sending:
169
172
  The bottom of the email must preserve message -> reply -> message -> reply without dropping previous answers.
170
173
 
171
174
  == SEND VIA `nexo-send-reply.py` ==
172
- [[python_executable]] [[send_reply_script]] --to X --cc Y --subject 'Re: Z' --in-reply-to '<msgid>' --references '<refs>' --body-file /tmp/nexo-reply-UID.txt --quote-file /tmp/nexo-quote-UID.txt --quote-from 'Name <email>' --quote-date 'date' --thread-file /tmp/nexo-thread-UID.txt [--attach /path/to/file]
175
+ [[python_executable]] [[send_reply_script]] --to X --cc Y --subject 'Re: Z' --in-reply-to '<msgid>' --references '<refs>' --body-file /tmp/nexo-reply-UID.txt --quote-file /tmp/nexo-quote-UID.txt --quote-from 'Name <email>' --quote-date 'date' --thread-file /tmp/nexo-thread-UID.txt --classify-as resolution [--attach /path/to/file]
176
+
177
+ Use a different `--classify-as` value only when the lifecycle is truly different (`ack`, `commitment`, `replied`, `debt_flagged`).
173
178
 
174
179
  == ANTI-LOOP PROTECTION ==
175
180
  Do not reply to auto-replies, [[agent_email_label]] itself, `noreply@`,
@@ -2924,6 +2924,51 @@
2924
2924
  },
2925
2925
  "triggers_after": []
2926
2926
  },
2927
+ "nexo_closure_triage": {
2928
+ "description": "Triage a closure item without executing its source action",
2929
+ "category": "closure",
2930
+ "source": "server",
2931
+ "requires": [],
2932
+ "provides": [
2933
+ "closure_triage_result"
2934
+ ],
2935
+ "internal_calls": [],
2936
+ "enforcement": {
2937
+ "level": "none",
2938
+ "rules": []
2939
+ },
2940
+ "triggers_after": []
2941
+ },
2942
+ "nexo_closure_link": {
2943
+ "description": "Link a closure item to a task, workflow, followup, outcome, or learning",
2944
+ "category": "closure",
2945
+ "source": "server",
2946
+ "requires": [],
2947
+ "provides": [
2948
+ "closure_item_link"
2949
+ ],
2950
+ "internal_calls": [],
2951
+ "enforcement": {
2952
+ "level": "none",
2953
+ "rules": []
2954
+ },
2955
+ "triggers_after": []
2956
+ },
2957
+ "nexo_closure_snapshot": {
2958
+ "description": "Write and return an Operational Closure Plane daily snapshot",
2959
+ "category": "closure",
2960
+ "source": "server",
2961
+ "requires": [],
2962
+ "provides": [
2963
+ "closure_snapshot"
2964
+ ],
2965
+ "internal_calls": [],
2966
+ "enforcement": {
2967
+ "level": "none",
2968
+ "rules": []
2969
+ },
2970
+ "triggers_after": []
2971
+ },
2927
2972
  "nexo_closure_verify": {
2928
2973
  "description": "Record verification evidence for a closure item",
2929
2974
  "category": "closure",
@@ -2954,6 +2999,81 @@
2954
2999
  },
2955
3000
  "triggers_after": []
2956
3001
  },
3002
+ "nexo_opportunity_refresh": {
3003
+ "description": "Generate Opportunity Orchestrator candidates from existing evidence",
3004
+ "category": "opportunity",
3005
+ "source": "server",
3006
+ "requires": [],
3007
+ "provides": [
3008
+ "opportunity_candidates"
3009
+ ],
3010
+ "internal_calls": [],
3011
+ "enforcement": {
3012
+ "level": "none",
3013
+ "rules": []
3014
+ },
3015
+ "triggers_after": []
3016
+ },
3017
+ "nexo_opportunity_queue": {
3018
+ "description": "Return up to three evidence-backed Opportunity Orchestrator proposals",
3019
+ "category": "opportunity",
3020
+ "source": "server",
3021
+ "requires": [],
3022
+ "provides": [
3023
+ "opportunity_proposals"
3024
+ ],
3025
+ "internal_calls": [],
3026
+ "enforcement": {
3027
+ "level": "none",
3028
+ "rules": []
3029
+ },
3030
+ "triggers_after": []
3031
+ },
3032
+ "nexo_opportunity_get": {
3033
+ "description": "Return one opportunity with evidence and read-only preparations",
3034
+ "category": "opportunity",
3035
+ "source": "server",
3036
+ "requires": [],
3037
+ "provides": [
3038
+ "opportunity"
3039
+ ],
3040
+ "internal_calls": [],
3041
+ "enforcement": {
3042
+ "level": "none",
3043
+ "rules": []
3044
+ },
3045
+ "triggers_after": []
3046
+ },
3047
+ "nexo_opportunity_feedback": {
3048
+ "description": "Record proposal feedback and apply suppression when requested",
3049
+ "category": "opportunity",
3050
+ "source": "server",
3051
+ "requires": [],
3052
+ "provides": [
3053
+ "opportunity_feedback"
3054
+ ],
3055
+ "internal_calls": [],
3056
+ "enforcement": {
3057
+ "level": "standard",
3058
+ "rules": []
3059
+ },
3060
+ "triggers_after": []
3061
+ },
3062
+ "nexo_opportunity_suppress": {
3063
+ "description": "Suppress repeated Opportunity Orchestrator suggestions by scope",
3064
+ "category": "opportunity",
3065
+ "source": "server",
3066
+ "requires": [],
3067
+ "provides": [
3068
+ "opportunity_suppression"
3069
+ ],
3070
+ "internal_calls": [],
3071
+ "enforcement": {
3072
+ "level": "standard",
3073
+ "rules": []
3074
+ },
3075
+ "triggers_after": []
3076
+ },
2957
3077
  "nexo_media_memory_add": {
2958
3078
  "description": "Store non-text artifact metadata",
2959
3079
  "category": "media",