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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/bin/nexo-brain.js +7 -1
- package/bin/nexo-managed-mcp.js +42 -4
- package/package.json +1 -1
- package/src/auto_update.py +7 -0
- package/src/cli.py +69 -0
- package/src/closure_plane.py +389 -8
- package/src/db/_schema.py +205 -2
- package/src/managed_mcp/catalog.py +6 -0
- package/src/managed_mcp/reconcile.py +198 -0
- package/src/opportunity_orchestrator.py +933 -0
- package/src/product_knowledge/catalog.json +53 -3
- package/src/scripts/nexo-email-monitor.py +71 -5
- package/src/scripts/nexo-send-reply.py +15 -2
- package/src/server.py +122 -2
- package/templates/core-prompts/email-monitor.md +6 -1
- package/tool-enforcement-map.json +120 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"updated_at": "2026-06-
|
|
3
|
+
"updated_at": "2026-06-07",
|
|
4
4
|
"source_policy": "Brain owns stable product semantics. Desktop manifests describe installed UI surfaces. Backend endpoints own live state, prices, balances, tickets and provisioning.",
|
|
5
5
|
"capabilities": [
|
|
6
6
|
{
|
|
@@ -210,6 +210,7 @@
|
|
|
210
210
|
"nexo/src/server.py:nexo_support_ticket_list",
|
|
211
211
|
"nexo/src/server.py:nexo_support_ticket_create",
|
|
212
212
|
"nexo-desktop/renderer/react/panels/Settings/tabs/SupportTab.jsx",
|
|
213
|
+
"nexo-desktop-web SupportTicketController",
|
|
213
214
|
"nexo-desktop-web routes/web.php"
|
|
214
215
|
],
|
|
215
216
|
"surfaces": ["Desktop Support settings", "Brain support-ticket tools", "Backend support API"],
|
|
@@ -249,12 +250,19 @@
|
|
|
249
250
|
"nexo/src/system_catalog.py:nexo_provider_proxy",
|
|
250
251
|
"nexo/src/system_catalog.py:nexo_provider_models",
|
|
251
252
|
"nexo-desktop/renderer/react/panels/Settings/tabs/SubscriptionsTab.jsx",
|
|
253
|
+
"nexo-desktop-web CreditsController",
|
|
254
|
+
"nexo-desktop-web NexoCreditsController",
|
|
252
255
|
"nexo-desktop-web ProviderProxyController",
|
|
253
256
|
"nexo-desktop-web ProviderCatalogService"
|
|
254
257
|
],
|
|
255
|
-
"surfaces": [
|
|
258
|
+
"surfaces": [
|
|
259
|
+
"Desktop Subscriptions/Credits",
|
|
260
|
+
"Backend credits API",
|
|
261
|
+
"Backend provider-proxy API",
|
|
262
|
+
"Admin NEXO Credits credential/readiness panel"
|
|
263
|
+
],
|
|
256
264
|
"live_state": {
|
|
257
|
-
"source": "backend provider-proxy platforms/models/estimate endpoints",
|
|
265
|
+
"source": "backend credits and provider-proxy platforms/models/estimate endpoints",
|
|
258
266
|
"max_age": "per backend call",
|
|
259
267
|
"fallback": "Mark provider/model support as unknown or planned if not returned by backend."
|
|
260
268
|
},
|
|
@@ -277,6 +285,47 @@
|
|
|
277
285
|
"must_not_say": ["Do not infer provider availability from marketing copy."]
|
|
278
286
|
}
|
|
279
287
|
},
|
|
288
|
+
{
|
|
289
|
+
"id": "nexo_managed_communications_providers",
|
|
290
|
+
"title": "Managed Communications Providers",
|
|
291
|
+
"category": "credits",
|
|
292
|
+
"layer": "backend+desktop",
|
|
293
|
+
"status": "live",
|
|
294
|
+
"summary": "NEXO can use managed voice, SMS, WhatsApp, mass-email and voice-runtime provider surfaces when backend readiness, credits and spend controls allow it.",
|
|
295
|
+
"aliases": ["managed voice", "managed sms", "managed whatsapp", "mass email", "email mass", "voice", "sms", "whatsapp", "provider communications"],
|
|
296
|
+
"source_refs": [
|
|
297
|
+
"nexo-desktop-web NexoVapiController",
|
|
298
|
+
"nexo-desktop-web NexoTwilioController",
|
|
299
|
+
"nexo-desktop-web NexoWazionWhatsappController",
|
|
300
|
+
"nexo-desktop-web NexoEmailMassController",
|
|
301
|
+
"nexo-desktop-web VoiceController",
|
|
302
|
+
"nexo-desktop-web ProviderCostResolverService"
|
|
303
|
+
],
|
|
304
|
+
"surfaces": ["Backend managed communications APIs", "NEXO Credits managed provider flows", "Desktop provider request flows"],
|
|
305
|
+
"live_state": {
|
|
306
|
+
"source": "backend managed communications provider APIs + credits ledger",
|
|
307
|
+
"max_age": "per backend call",
|
|
308
|
+
"fallback": "Treat provider availability as unknown or not configured if backend route, readiness or cost checks are unavailable."
|
|
309
|
+
},
|
|
310
|
+
"actions": {
|
|
311
|
+
"read": ["List managed resources", "Estimate cost", "Read job or request status"],
|
|
312
|
+
"write": ["Provision phone/assistant/session/domain after approval", "Send message/email or start call after explicit authorization"]
|
|
313
|
+
},
|
|
314
|
+
"safety": {
|
|
315
|
+
"data_touched": ["phone numbers", "email recipients", "message bodies", "call metadata", "provider job payloads", "credits balance"],
|
|
316
|
+
"data_origin": "user-approved communication payloads and backend provider APIs",
|
|
317
|
+
"consent_required": "yes before sending communications, starting calls or provisioning managed provider resources",
|
|
318
|
+
"confirmation_required": "yes before billable provider operations or external communications",
|
|
319
|
+
"credential_policy": "Provider credentials live in backend credential storage; never reveal them.",
|
|
320
|
+
"retention": "Provider request records, suppression lists, message logs and ledger retention follow backend policy.",
|
|
321
|
+
"audit": "Provider job IDs, request IDs and ledger entries are required evidence.",
|
|
322
|
+
"forbidden_actions": ["Expose vendor/provider names as the product promise", "Start calls or send messages/emails without explicit authorization", "Bypass credit estimation or spend limits"]
|
|
323
|
+
},
|
|
324
|
+
"answer_guidance": {
|
|
325
|
+
"must_say": ["Describe user-facing outcomes, not vendor internals, unless the operator asks for implementation detail.", "Spend, readiness and job state are live backend facts."],
|
|
326
|
+
"must_not_say": ["Do not promise a call, SMS, WhatsApp message or email campaign without backend readiness and authorization evidence."]
|
|
327
|
+
}
|
|
328
|
+
},
|
|
280
329
|
{
|
|
281
330
|
"id": "nexo_managed_cloud_edge",
|
|
282
331
|
"title": "Managed Cloud And Edge",
|
|
@@ -326,6 +375,7 @@
|
|
|
326
375
|
"aliases": ["cards", "protocol cards", "workflow cards", "procedures"],
|
|
327
376
|
"source_refs": [
|
|
328
377
|
"nexo/src/server.py:nexo_card_match",
|
|
378
|
+
"nexo-desktop-web CardController",
|
|
329
379
|
"nexo-desktop-web CardCatalogService",
|
|
330
380
|
"nexo-desktop/prompts/system/conversation-bootstrap.md"
|
|
331
381
|
],
|
|
@@ -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 (
|
|
911
|
-
AND timestamp > ?
|
|
940
|
+
AND event IN ({placeholders})
|
|
912
941
|
LIMIT 1
|
|
913
942
|
""",
|
|
914
|
-
(email_id,
|
|
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(
|
|
1047
|
+
def nexo_closure_next(
|
|
1048
|
+
limit: int = 10,
|
|
1049
|
+
include_waiting: bool = False,
|
|
1050
|
+
source: str = "",
|
|
1051
|
+
kind: str = "",
|
|
1052
|
+
state: str = "",
|
|
1053
|
+
max_risk: float = 0.0,
|
|
1054
|
+
area: str = "",
|
|
1055
|
+
) -> str:
|
|
1048
1056
|
"""Return the next ranked closure items without executing source actions."""
|
|
1049
1057
|
from closure_plane import handle_closure_next
|
|
1050
1058
|
|
|
@@ -1052,7 +1060,11 @@ def nexo_closure_next(limit: int = 10, include_waiting: bool = False, source: st
|
|
|
1052
1060
|
clean_limit = max(1, min(int(limit or 10), 100))
|
|
1053
1061
|
except Exception:
|
|
1054
1062
|
clean_limit = 10
|
|
1055
|
-
|
|
1063
|
+
try:
|
|
1064
|
+
clean_max_risk = float(max_risk or 0.0)
|
|
1065
|
+
except Exception:
|
|
1066
|
+
clean_max_risk = 0.0
|
|
1067
|
+
return handle_closure_next(clean_limit, bool(include_waiting), source, kind, state, clean_max_risk, area)
|
|
1056
1068
|
|
|
1057
1069
|
|
|
1058
1070
|
@mcp.tool
|
|
@@ -1063,6 +1075,56 @@ def nexo_closure_item_get(item_id: str) -> str:
|
|
|
1063
1075
|
return handle_closure_item_get(item_id)
|
|
1064
1076
|
|
|
1065
1077
|
|
|
1078
|
+
@mcp.tool
|
|
1079
|
+
def nexo_closure_triage(
|
|
1080
|
+
item_id: str,
|
|
1081
|
+
state: str = "",
|
|
1082
|
+
kind: str = "",
|
|
1083
|
+
blocker_reason: str = "",
|
|
1084
|
+
next_action: str = "",
|
|
1085
|
+
evidence_required: str = "",
|
|
1086
|
+
owner: str = "",
|
|
1087
|
+
capability_required: str = "",
|
|
1088
|
+
capability_status: str = "",
|
|
1089
|
+
duplicate_of: str = "",
|
|
1090
|
+
) -> str:
|
|
1091
|
+
"""Triage a closure item without executing its source action."""
|
|
1092
|
+
from closure_plane import handle_closure_triage
|
|
1093
|
+
|
|
1094
|
+
return handle_closure_triage(
|
|
1095
|
+
item_id,
|
|
1096
|
+
state,
|
|
1097
|
+
kind,
|
|
1098
|
+
blocker_reason,
|
|
1099
|
+
next_action,
|
|
1100
|
+
evidence_required,
|
|
1101
|
+
owner,
|
|
1102
|
+
capability_required,
|
|
1103
|
+
capability_status,
|
|
1104
|
+
duplicate_of,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
@mcp.tool
|
|
1109
|
+
def nexo_closure_link(item_id: str, link_type: str, link_id: str, relation: str = "related") -> str:
|
|
1110
|
+
"""Link a closure item to a task, workflow, followup, outcome, or learning."""
|
|
1111
|
+
from closure_plane import handle_closure_link
|
|
1112
|
+
|
|
1113
|
+
return handle_closure_link(item_id, link_type, link_id, relation)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
@mcp.tool
|
|
1117
|
+
def nexo_closure_snapshot(refresh: bool = True, snapshot_date: str = "", limit: int = 10) -> str:
|
|
1118
|
+
"""Write and return an Operational Closure Plane daily snapshot."""
|
|
1119
|
+
from closure_plane import handle_closure_snapshot
|
|
1120
|
+
|
|
1121
|
+
try:
|
|
1122
|
+
clean_limit = max(1, min(int(limit or 10), 100))
|
|
1123
|
+
except Exception:
|
|
1124
|
+
clean_limit = 10
|
|
1125
|
+
return handle_closure_snapshot(bool(refresh), snapshot_date, clean_limit)
|
|
1126
|
+
|
|
1127
|
+
|
|
1066
1128
|
@mcp.tool
|
|
1067
1129
|
def nexo_closure_verify(item_id: str, evidence: str) -> str:
|
|
1068
1130
|
"""Record verification evidence for a closure item."""
|
|
@@ -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",
|