nexo-brain 7.30.12 → 7.30.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.12",
3
+ "version": "7.30.15",
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,13 @@
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.12` is the current packaged-runtime line. Patch release over v7.30.11 - Desktop-managed email credentials now resolve through the shared credential helper, so Email NEXO migrations and CLI reads use keychain-backed markers before legacy SQLite fallback.
21
+ Version `7.30.15` is the current packaged-runtime line. Patch release over v7.30.14 - Email NEXO reply lifecycle closure now handles affirmative actionable replies and prevents already-closed processed threads from reopening.
22
+
23
+ Previously in `7.30.14`: patch release over v7.30.13 - support tickets, provider capability discovery, task-close rearming, internal audit followups, and the memory-observation watchdog are aligned for Desktop-managed agents.
24
+
25
+ Previously in `7.30.13`: patch release over v7.30.12 - Email NEXO monitor prompts now require a full IMAP body fetch before replying, and managed email defaults use the MXroute `Sent` folder instead of `INBOX.Sent`.
26
+
27
+ Previously in `7.30.12`: patch release over v7.30.11 - Desktop-managed email credentials now resolve through the shared credential helper, so Email NEXO migrations and CLI reads use keychain-backed markers before legacy SQLite fallback.
22
28
 
23
29
  Previously in `7.30.11`: patch release over v7.30.10 - the installer and npm postinstall path now stamp the verified repair baseline too, so the first update from older packaged installs is covered without a manual second run.
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.12",
3
+ "version": "7.30.15",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
package/src/cli_email.py CHANGED
@@ -69,7 +69,7 @@ def _sent_folder_from_account(account: dict | None) -> str:
69
69
  if isinstance(account, dict) and isinstance(account.get("metadata"), dict):
70
70
  metadata = account.get("metadata") or {}
71
71
  value = str(metadata.get("sent_folder") or "").strip()
72
- return value or "INBOX.Sent"
72
+ return value or "Sent"
73
73
 
74
74
 
75
75
  def _store_credential(service: str, key: str, value: str) -> None:
@@ -139,7 +139,7 @@ def cmd_email_setup(args) -> int:
139
139
  "",
140
140
  )
141
141
  trusted = [d.strip() for d in trusted_raw.split(",") if d.strip()]
142
- sent_folder = _prompt("IMAP sent folder", "INBOX.Sent").strip() or "INBOX.Sent"
142
+ sent_folder = _prompt("IMAP sent folder", "Sent").strip() or "Sent"
143
143
 
144
144
  role = _prompt(
145
145
  "Account role: inbox (read only) / outbox (send only) / both",
@@ -712,7 +712,7 @@ def register_email_parser(subparsers) -> None:
712
712
  s.add_argument("--operator", dest="operator", default="")
713
713
  s.add_argument("--trusted-domains", dest="trusted_domains", default="")
714
714
  s.add_argument("--sent-folder", dest="sent_folder", default=None,
715
- help="IMAP folder where sent copies should be appended (default: INBOX.Sent).")
715
+ help="IMAP folder where sent copies should be appended (default: Sent).")
716
716
  s.add_argument("--role", dest="role", default="both", choices=["inbox", "outbox", "both"])
717
717
  read_group = s.add_mutually_exclusive_group()
718
718
  read_group.add_argument("--can-read", dest="can_read", action="store_true", default=None)
@@ -328,6 +328,20 @@
328
328
  "run_on_boot": true,
329
329
  "run_on_wake": true
330
330
  },
331
+ {
332
+ "id": "memory-observation-watchdog",
333
+ "script": "scripts/nexo-memory-observation-watchdog.py",
334
+ "interval_seconds": 900,
335
+ "description": "Keep Memory Observations v2 queue convergent and repair stale/missed observation rows",
336
+ "core": true,
337
+ "optional": "automation",
338
+ "recovery_policy": "catchup",
339
+ "idempotent": true,
340
+ "max_catchup_age": 7200,
341
+ "stuck_after_seconds": 600,
342
+ "run_on_boot": true,
343
+ "run_on_wake": true
344
+ },
331
345
  {
332
346
  "id": "email-monitor",
333
347
  "script": "scripts/nexo-email-monitor.py",
@@ -57,7 +57,7 @@ def _account_to_runtime_account(account: dict) -> dict:
57
57
  account.get("credential_key", ""),
58
58
  )
59
59
  metadata = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
60
- sent_folder = str(metadata.get("sent_folder") or "").strip() or "INBOX.Sent"
60
+ sent_folder = str(metadata.get("sent_folder") or "").strip() or "Sent"
61
61
  return {
62
62
  "label": account.get("label", ""),
63
63
  "email": account.get("email", ""),
@@ -475,6 +475,7 @@ class HeadlessEnforcer:
475
475
  # per task cycle. Cleared on skill_match OR task_close.
476
476
  self._multi_step_event_fired: bool = False
477
477
  self._post_close_cooldown_until: float = 0.0
478
+ self._last_task_close_user_message_count: int = -1
478
479
  # A headless nexo_stop is terminal for the automation cycle. Once
479
480
  # seen, periodic/conditional reminders stay suppressed so cron
480
481
  # runners can reach TURN_END instead of reopening the task loop.
@@ -2287,6 +2288,7 @@ class HeadlessEnforcer:
2287
2288
  # v7.6 task_close observed → rearm conditional for the companion
2288
2289
  # open tool so the next task cycle re-opens the obligation.
2289
2290
  if name == "nexo_task_close":
2291
+ self._last_task_close_user_message_count = int(self.user_message_count or 0)
2290
2292
  self.reset_task_cycle("nexo_task_open")
2291
2293
  self._start_post_close_cooldown()
2292
2294
  self._resolve_r17_commitments_from_task_close(tool_input)
@@ -2522,6 +2524,17 @@ class HeadlessEnforcer:
2522
2524
  the recent tool mix shows edit/execute/delegate signals (Edit,
2523
2525
  Write, Bash with mutation commands, Task dispatch).
2524
2526
  """
2527
+ if getattr(self, "_session_stopped", False):
2528
+ return
2529
+ if self._post_close_cooldown_active():
2530
+ return
2531
+ # A closed task cycle must not re-open itself on idle/background
2532
+ # ticks. Rearm only after a new visible user message advances the
2533
+ # turn counter.
2534
+ last_close_raw = getattr(self, "_last_task_close_user_message_count", -1)
2535
+ last_close_count = int(last_close_raw) if last_close_raw is not None else -1
2536
+ if last_close_count >= 0 and int(self.user_message_count or 0) <= last_close_count:
2537
+ return
2525
2538
  for entry in self._conditional:
2526
2539
  tool = entry["tool"]
2527
2540
  rule = entry.get("rule", {})
@@ -321,6 +321,10 @@ def apply_learning_retroactively(
321
321
  created = 0
322
322
  now_epoch = datetime.now().timestamp()
323
323
  summary_matches = []
324
+ try:
325
+ followup_columns = {row["name"] for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
326
+ except Exception:
327
+ followup_columns = set()
324
328
  for m in capped:
325
329
  decision = m["decision"]
326
330
  followup_id = f"NF-RETRO-L{learning['id']}-D{decision['id']}"
@@ -334,18 +338,36 @@ def apply_learning_retroactively(
334
338
  f"SELECT id, domain, decision, based_on, status FROM decisions WHERE id = {int(decision['id'])}"
335
339
  )
336
340
  try:
341
+ insert_columns = [
342
+ "id",
343
+ "description",
344
+ "date",
345
+ "status",
346
+ "verification",
347
+ "created_at",
348
+ "updated_at",
349
+ "priority",
350
+ ]
351
+ values = [
352
+ followup_id,
353
+ description,
354
+ None,
355
+ "PENDING",
356
+ verification,
357
+ now_epoch,
358
+ now_epoch,
359
+ learning.get("priority") or "medium",
360
+ ]
361
+ if "internal" in followup_columns:
362
+ insert_columns.append("internal")
363
+ values.append(1)
364
+ if "owner" in followup_columns:
365
+ insert_columns.append("owner")
366
+ values.append("agent")
367
+ placeholders = ", ".join("?" for _ in insert_columns)
337
368
  conn.execute(
338
- "INSERT OR REPLACE INTO followups (id, description, date, status, "
339
- "verification, created_at, updated_at, priority) "
340
- "VALUES (?, ?, NULL, 'PENDING', ?, ?, ?, ?)",
341
- (
342
- followup_id,
343
- description,
344
- verification,
345
- now_epoch,
346
- now_epoch,
347
- learning.get("priority") or "medium",
348
- ),
369
+ f"INSERT OR REPLACE INTO followups ({', '.join(insert_columns)}) VALUES ({placeholders})",
370
+ tuple(values),
349
371
  )
350
372
  created += 1
351
373
  summary_matches.append({
@@ -271,7 +271,8 @@ def _ensure_protocol_debt(conn: sqlite3.Connection, *, debt_type: str, severity:
271
271
 
272
272
 
273
273
  def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
274
- verification: str, reasoning: str, priority: str = "high") -> str:
274
+ verification: str, reasoning: str, priority: str = "high",
275
+ internal: int = 1, owner: str = "agent") -> str:
275
276
  if not _table_exists(conn, "followups"):
276
277
  return ""
277
278
  # Content fingerprint, not security-sensitive.
@@ -300,6 +301,10 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
300
301
  }
301
302
  if "priority" in columns:
302
303
  update_fields["priority"] = priority
304
+ if "internal" in columns:
305
+ update_fields["internal"] = int(bool(internal))
306
+ if "owner" in columns:
307
+ update_fields["owner"] = owner
303
308
  closed_status = str(existing_id_row["status"] or "").upper()
304
309
  if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
305
310
  update_fields["status"] = "PENDING"
@@ -324,6 +329,8 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
324
329
  reasoning=reasoning,
325
330
  recurrence=None,
326
331
  priority=priority,
332
+ internal=internal,
333
+ owner=owner,
327
334
  )
328
335
  if result.get("error"):
329
336
  return ""
@@ -985,6 +992,30 @@ def check_db_size():
985
992
  size_mb = NEXO_DB.stat().st_size / (1024 * 1024)
986
993
  if size_mb > 100:
987
994
  finding("WARN", "database", f"nexo.db is {size_mb:.1f} MB — consider cleanup")
995
+ # Guard against runaway growth of the local context index. Root cause of the
996
+ # 2026-06-03 disk burst: local-context.db reached 268 GB unseen because the
997
+ # only DB checked here was nexo.db (learning #824). Surface oversized runtime
998
+ # DBs early — well before the total-disk WARN@80% / FAIL would trip.
999
+ try:
1000
+ import paths as paths_module
1001
+
1002
+ local_ctx = paths_module.memory_dir() / "local-context.db"
1003
+ if local_ctx.exists():
1004
+ size_gb = local_ctx.stat().st_size / (1024 ** 3)
1005
+ if size_gb > 60:
1006
+ finding(
1007
+ "ERROR",
1008
+ "database",
1009
+ f"local-context.db is {size_gb:.1f} GB — local index runaway; purge + VACUUM (see roots/exclusions)",
1010
+ )
1011
+ elif size_gb > 25:
1012
+ finding(
1013
+ "WARN",
1014
+ "database",
1015
+ f"local-context.db is {size_gb:.1f} GB — local index growing; review indexed roots/exclusions",
1016
+ )
1017
+ except Exception as exc:
1018
+ finding("WARN", "database", f"Could not check local-context.db size: {exc}")
988
1019
 
989
1020
 
990
1021
  def check_stale_sessions():
@@ -1821,8 +1852,14 @@ def check_cognitive_health():
1821
1852
  if metrics["total_retrievals"] > 0:
1822
1853
  finding("INFO", "cognitive-metrics",
1823
1854
  f"7d: {metrics['total_retrievals']} retrievals, relevance={metrics['retrieval_relevance_pct']}%")
1824
- if metrics["retrieval_relevance_pct"] < 50 and metrics["total_retrievals"] >= 5:
1855
+ if metrics["retrieval_relevance_pct"] < 50 and metrics["total_retrievals"] >= 30:
1825
1856
  finding("ERROR", "cognitive-metrics", f"Relevance critically low: {metrics['retrieval_relevance_pct']}%")
1857
+ elif metrics["retrieval_relevance_pct"] < 50 and metrics["total_retrievals"] < 30:
1858
+ finding(
1859
+ "INFO",
1860
+ "cognitive-metrics",
1861
+ f"Relevance below 50% but sample is low ({metrics['total_retrievals']} retrievals)",
1862
+ )
1826
1863
 
1827
1864
  repeats = cog.check_repeat_errors()
1828
1865
  if repeats["new_count"] > 0 and repeats["repeat_rate_pct"] > 30:
@@ -106,7 +106,7 @@ def main(argv: list[str]) -> int:
106
106
  "claude_binary": legacy.get("claude_binary", ""),
107
107
  "working_dir": legacy.get("working_dir", str(Path.home())),
108
108
  "max_process_time": legacy.get("max_process_time"),
109
- "sent_folder": legacy.get("sent_folder", "INBOX.Sent"),
109
+ "sent_folder": legacy.get("sent_folder", "Sent"),
110
110
  "operator_aliases": legacy_operator_aliases,
111
111
  }
112
112
 
@@ -140,7 +140,7 @@ def main(argv: list[str]) -> int:
140
140
  **metadata,
141
141
  **existing_metadata,
142
142
  "operator_aliases": merged_aliases,
143
- "sent_folder": str(existing_metadata.get("sent_folder") or metadata.get("sent_folder") or "INBOX.Sent"),
143
+ "sent_folder": str(existing_metadata.get("sent_folder") or metadata.get("sent_folder") or "Sent"),
144
144
  }
145
145
  normalized_role = str(existing.get("role") or "both")
146
146
  account = add_email_account(
@@ -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.")
@@ -1172,7 +1238,7 @@ def count_stuck_emails():
1172
1238
 
1173
1239
  # === Email-loss prevention (2026-04-14): pre-register + orphan recovery ===
1174
1240
  # Problem: if headless NEXO dies before marking email in BD, IMAP may have it
1175
- # marked SEEN (from nexo_email_read) but BD has no row. count_stuck_emails
1241
+ # marked SEEN (from an IMAP full-message read) but BD has no row. count_stuck_emails
1176
1242
  # cannot see it, and has_new_email returns 0. The email is lost.
1177
1243
  # Fix:
1178
1244
  # 1. Pre-register: INSERT status='pending' rows BEFORE launching NEXO, using
@@ -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
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ # nexo: name=memory-observation-watchdog
3
+ # nexo: description=Keep Memory Observations v2 queue convergent without user-visible followups.
4
+ # nexo: category=memory
5
+ # nexo: runtime=python
6
+ # nexo: timeout=600
7
+ # nexo: cron_id=memory-observation-watchdog
8
+ # nexo: interval_seconds=900
9
+ # nexo: schedule_required=true
10
+ # nexo: recovery_policy=catchup
11
+ # nexo: run_on_boot=true
12
+ # nexo: run_on_wake=true
13
+ # nexo: idempotent=true
14
+ # nexo: max_catchup_age=7200
15
+ # nexo: stuck_after_seconds=600
16
+ # nexo: doctor_allow_db=true
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+
27
+ def _bootstrap_nexo_code(default_repo_src: Path) -> Path:
28
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
29
+ raw_env = os.environ.get("NEXO_CODE", "")
30
+ candidates: list[Path] = []
31
+ if raw_env:
32
+ raw = Path(raw_env).expanduser()
33
+ candidates.extend([raw, raw / "core"])
34
+ candidates.extend([default_repo_src, nexo_home / "core", nexo_home])
35
+ seen: set[str] = set()
36
+ for candidate in candidates:
37
+ key = str(candidate)
38
+ if key in seen:
39
+ continue
40
+ seen.add(key)
41
+ if (candidate / "paths.py").is_file() or (candidate / "server.py").is_file():
42
+ if str(candidate) not in sys.path:
43
+ sys.path.insert(0, str(candidate))
44
+ return candidate
45
+ fallback = candidates[0]
46
+ if str(fallback) not in sys.path:
47
+ sys.path.insert(0, str(fallback))
48
+ return fallback
49
+
50
+
51
+ _SCRIPT_DIR = Path(__file__).resolve().parent
52
+ NEXO_CODE = _bootstrap_nexo_code(_SCRIPT_DIR.parent)
53
+
54
+ from paths import logs_dir, operations_dir # noqa: E402
55
+ from memory_observation_processor import process_incremental, queue_health # noqa: E402
56
+
57
+
58
+ LOG_FILE = logs_dir() / "memory-observation-watchdog.log"
59
+ SUMMARY_FILE = operations_dir() / "memory-observation-watchdog-latest.json"
60
+ DEFAULT_SLA_SECONDS = int(os.environ.get("NEXO_MEMORY_OBSERVATION_SLA_SECONDS", "3600") or "3600")
61
+ DEFAULT_PROCESS_LIMIT = int(os.environ.get("NEXO_MEMORY_OBSERVATION_PROCESS_LIMIT", "100") or "100")
62
+ DEFAULT_BACKFILL_LIMIT = int(os.environ.get("NEXO_MEMORY_OBSERVATION_BACKFILL_LIMIT", "100") or "100")
63
+
64
+
65
+ def log(message: str) -> None:
66
+ ts = datetime.now().isoformat(timespec="seconds")
67
+ line = f"[{ts}] {message}"
68
+ print(line, flush=True)
69
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
70
+ with LOG_FILE.open("a", encoding="utf-8") as handle:
71
+ handle.write(line + "\n")
72
+
73
+
74
+ def write_summary(payload: dict) -> None:
75
+ SUMMARY_FILE.parent.mkdir(parents=True, exist_ok=True)
76
+ tmp = SUMMARY_FILE.with_suffix(SUMMARY_FILE.suffix + ".tmp")
77
+ tmp.write_text(json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True), encoding="utf-8")
78
+ tmp.replace(SUMMARY_FILE)
79
+
80
+
81
+ def should_process(health: dict) -> bool:
82
+ if not health.get("ok", False):
83
+ return False
84
+ if bool(health.get("skipped", False)):
85
+ return False
86
+ return bool(
87
+ not health.get("healthy", True)
88
+ or int(health.get("pending", 0) or 0) > 0
89
+ or int(health.get("unqueued_events", 0) or 0) > 0
90
+ or int(health.get("processed_missing_observations", 0) or 0) > 0
91
+ )
92
+
93
+
94
+ def main() -> int:
95
+ log("=== Memory Observation Watchdog starting ===")
96
+ health = queue_health(pending_sla_seconds=DEFAULT_SLA_SECONDS)
97
+ payload: dict = {
98
+ "ok": bool(health.get("ok", True)),
99
+ "processed": False,
100
+ "health_before": health,
101
+ "timestamp": datetime.now().isoformat(timespec="seconds"),
102
+ }
103
+
104
+ if should_process(health):
105
+ result = process_incremental(
106
+ process_limit=DEFAULT_PROCESS_LIMIT,
107
+ backfill_limit=DEFAULT_BACKFILL_LIMIT,
108
+ pending_sla_seconds=DEFAULT_SLA_SECONDS,
109
+ )
110
+ payload.update({"processed": True, "result": result})
111
+ log(
112
+ "Processed queue: "
113
+ f"backfill={result.get('backfill', {}).get('enqueued', 0)} "
114
+ f"processed={result.get('processed', {}).get('processed', 0)} "
115
+ f"healthy={result.get('healthy', True)}"
116
+ )
117
+ else:
118
+ log("Queue healthy or unavailable; no processing needed.")
119
+
120
+ write_summary(payload)
121
+ return 0 if payload.get("ok", True) else 1
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
@@ -270,7 +270,7 @@ def _has_open_action(conn, email_id):
270
270
  return not latest_done or latest_done < latest_open
271
271
 
272
272
 
273
- def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to="", cc="", message_id="", db_path=EMAIL_DB_PATH):
273
+ def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to="", cc="", message_id="", db_path=EMAIL_DB_PATH, classify_override=""):
274
274
  try:
275
275
  conn = sqlite3.connect(db_path)
276
276
  conn.row_factory = sqlite3.Row
@@ -280,7 +280,7 @@ def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to
280
280
  conn.close()
281
281
  return None
282
282
 
283
- event = classify_reply_event(body_text)
283
+ event = (classify_override or "").strip() or classify_reply_event(body_text)
284
284
  detail = normalize_reply_text(body_text)[:200]
285
285
  meta = json.dumps(
286
286
  {
@@ -445,7 +445,7 @@ def save_to_sent(config, raw_message: bytes, folder: str = ""):
445
445
  imap_host = str((config or {}).get("imap_host") or "").strip()
446
446
  if not imap_host or not str((config or {}).get("password") or ""):
447
447
  return False
448
- resolved_folder = str(folder or config.get("sent_folder") or "INBOX.Sent").strip() or "INBOX.Sent"
448
+ resolved_folder = str(folder or config.get("sent_folder") or "Sent").strip() or "Sent"
449
449
  client = imaplib.IMAP4_SSL(imap_host, int(config.get("imap_port") or 993))
450
450
  try:
451
451
  client.login(config["email"], config["password"])
@@ -476,6 +476,18 @@ def build_parser():
476
476
  parser.add_argument("--quote-date", default="")
477
477
  parser.add_argument("--thread-file", default="", help="Full thread history file (all previous messages)")
478
478
  parser.add_argument("--attach", "--attachment", dest="attach", action="append", default=[], help="File to attach (can repeat)")
479
+ parser.add_argument(
480
+ "--classify-as",
481
+ dest="classify_as",
482
+ default="",
483
+ choices=["", "resolution", "ack", "commitment", "replied", "debt_flagged"],
484
+ help=(
485
+ "Force the reply lifecycle event instead of heuristic auto-classification. "
486
+ "Use when the reply is substantive but opens with a short affirmation that the "
487
+ "text heuristic would mis-read as 'ack' (escalation -> debt_flagged). "
488
+ "Empty (default) keeps automatic classification."
489
+ ),
490
+ )
479
491
  return parser
480
492
 
481
493
 
@@ -536,6 +548,7 @@ def main(argv=None):
536
548
  to=args.to,
537
549
  cc=args.cc,
538
550
  message_id=msg_id,
551
+ classify_override=(args.classify_as or "").strip(),
539
552
  )
540
553
  try:
541
554
  record_sent_email(
package/src/server.py CHANGED
@@ -120,7 +120,13 @@ from plugins.workflow import (
120
120
  )
121
121
  from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
122
122
  from tools_guardian import handle_guardian_rule_override
123
- from tools_api_call import handle_api_call, handle_create_app_token
123
+ from tools_api_call import (
124
+ handle_api_call,
125
+ handle_create_app_token,
126
+ handle_support_ticket_create,
127
+ handle_support_ticket_list,
128
+ handle_support_ticket_read,
129
+ )
124
130
  from runtime_versioning import (
125
131
  RestartRequiredMiddleware,
126
132
  build_mcp_status,
@@ -2773,6 +2779,28 @@ def nexo_api_call(
2773
2779
  return handle_api_call(method, path, body_json, idempotency_key, headers_json, base_url)
2774
2780
 
2775
2781
 
2782
+ @mcp.tool
2783
+ def nexo_support_ticket_list(status: str = "", limit: int = 20) -> str:
2784
+ """List real support tickets from the NEXO backend for the signed-in Desktop user.
2785
+
2786
+ Use this when the user asks about support tickets or bug reports. Do not
2787
+ substitute a private followup for an actual product support ticket.
2788
+ """
2789
+ return handle_support_ticket_list(status, limit)
2790
+
2791
+
2792
+ @mcp.tool
2793
+ def nexo_support_ticket_read(ticket_id: str) -> str:
2794
+ """Read one real support ticket from the NEXO backend by id."""
2795
+ return handle_support_ticket_read(ticket_id)
2796
+
2797
+
2798
+ @mcp.tool
2799
+ def nexo_support_ticket_create(subject: str, message: str, priority: str = "normal") -> str:
2800
+ """Create a real NEXO support ticket for a product bug/setup issue."""
2801
+ return handle_support_ticket_create(subject, message, priority)
2802
+
2803
+
2776
2804
  @mcp.tool
2777
2805
  def nexo_create_app_token(
2778
2806
  name: str,
@@ -28,6 +28,7 @@ SECTION_ORDER = (
28
28
  "skills",
29
29
  "scripts",
30
30
  "crons",
31
+ "product_capabilities",
31
32
  "projects",
32
33
  "artifacts",
33
34
  )
@@ -85,6 +86,8 @@ def _tool_category(name: str) -> str:
85
86
  return "workflow"
86
87
  if name.startswith("nexo_learning"):
87
88
  return "learnings"
89
+ if name.startswith("nexo_support_ticket"):
90
+ return "support"
88
91
  if name.startswith("nexo_guard") or name.startswith("nexo_task") or name.startswith("nexo_cortex"):
89
92
  return "protocol"
90
93
  return "general"
@@ -610,6 +613,128 @@ def _artifact_entries() -> list[dict]:
610
613
  ]
611
614
 
612
615
 
616
+ def _product_capability_entries() -> list[dict]:
617
+ """Static product contract map for agent self-discovery.
618
+
619
+ These entries are intentionally endpoint-level rather than marketing
620
+ copy. They let a fresh agent find the real backend surfaces before
621
+ guessing unsupported workflows.
622
+ """
623
+
624
+ return [
625
+ {
626
+ "kind": "product_capability",
627
+ "name": "nexo_support_tickets_api",
628
+ "display_name": "NEXO Support Tickets API",
629
+ "category": "support",
630
+ "description": "Create, list, read, reply, close and reopen real customer support tickets from Desktop/backend.",
631
+ "source": "nexo-desktop-web routes/web.php",
632
+ "endpoints": [
633
+ "GET /api/support/tickets",
634
+ "POST /api/support/tickets",
635
+ "GET /api/support/tickets/{supportTicket}",
636
+ "POST /api/support/tickets/{supportTicket}/messages",
637
+ "POST /api/support/tickets/{supportTicket}/close",
638
+ "POST /api/support/tickets/{supportTicket}/reopen",
639
+ ],
640
+ "notes": "Use this for product bug reports instead of creating private followups.",
641
+ },
642
+ {
643
+ "kind": "product_capability",
644
+ "name": "nexo_provider_proxy",
645
+ "display_name": "NEXO Credits Provider Proxy",
646
+ "category": "credits",
647
+ "description": "Billable proxy for provider calls. Platforms are discoverable; calls must pass credits, policy and app-token checks.",
648
+ "source": "nexo-desktop-web ProviderProxyController",
649
+ "endpoints": [
650
+ "GET /api/provider-proxy/platforms",
651
+ "GET /api/provider-proxy/models",
652
+ "POST /api/provider-proxy/estimate",
653
+ "POST /api/provider-proxy/call",
654
+ "GET /api/provider-proxy/requests/{providerRequest}",
655
+ ],
656
+ "notes": "Treat declared capabilities separately from invokable model support.",
657
+ },
658
+ {
659
+ "kind": "product_capability",
660
+ "name": "nexo_provider_models",
661
+ "display_name": "Provider Model Discovery",
662
+ "category": "credits",
663
+ "description": "Model/capability discovery endpoint for text, image, audio, vision and planned video support.",
664
+ "source": "nexo-desktop-web ProviderCatalogService",
665
+ "endpoints": [
666
+ "GET /api/provider-proxy/models?platform=openrouter",
667
+ "GET /api/provider-proxy/models?platform=openrouter&capability=video",
668
+ ],
669
+ "notes": "If a capability is not invokable, report it as planned/not_invokable instead of promising execution.",
670
+ },
671
+ {
672
+ "kind": "product_capability",
673
+ "name": "nexo_credits_cloud",
674
+ "display_name": "NEXO Credits Managed Cloud",
675
+ "category": "cloud",
676
+ "description": "Managed GCloud project provisioning/listing billed through NEXO Credits.",
677
+ "source": "nexo-desktop-web NexoCloudController",
678
+ "endpoints": [
679
+ "GET /api/nexo-cloud/projects",
680
+ "GET /api/nexo-cloud/sites",
681
+ "GET /api/nexo-cloud/deployments",
682
+ "GET /api/credits/cloud",
683
+ "GET /api/credits/projects",
684
+ "POST /api/nexo-cloud/provision",
685
+ "POST /api/nexo-cloud/token",
686
+ "POST /api/nexo-cloud/grants",
687
+ ],
688
+ "notes": "Read/list endpoints are safe discovery; provisioning remains a billable operation with spend limits.",
689
+ },
690
+ {
691
+ "kind": "product_capability",
692
+ "name": "nexo_edge_cloudflare",
693
+ "display_name": "NEXO Edge Cloudflare",
694
+ "category": "cloud",
695
+ "description": "Cloudflare zones, domains, DNS, redirects and jobs managed through NEXO Credits.",
696
+ "source": "nexo-desktop-web NexoEdgeController",
697
+ "endpoints": [
698
+ "GET /api/nexo-edge/assets",
699
+ "POST /api/nexo-edge/domains/check",
700
+ "POST /api/nexo-edge/domains/register",
701
+ "POST /api/nexo-edge/dns",
702
+ "POST /api/nexo-edge/redirects",
703
+ "GET /api/nexo-edge/jobs/{job}",
704
+ ],
705
+ },
706
+ {
707
+ "kind": "product_capability",
708
+ "name": "nexo_email_managed_agent_mailbox",
709
+ "display_name": "Email NEXO Managed Agent Mailbox",
710
+ "category": "email",
711
+ "description": "One managed IMAP/SMTP mailbox per NEXO license for the user's agent.",
712
+ "source": "nexo-desktop-web NexoEmailController + Desktop Email preferences",
713
+ "endpoints": [
714
+ "GET /api/nexo-email/account",
715
+ "POST /api/nexo-email/availability",
716
+ "POST /api/nexo-email/account/ensure",
717
+ "POST /api/nexo-email/account/connection",
718
+ ],
719
+ "notes": "If an account already exists, configure that mailbox; do not ask for a new address.",
720
+ },
721
+ {
722
+ "kind": "product_capability",
723
+ "name": "nexo_protocol_cards",
724
+ "display_name": "NEXO Protocol Cards",
725
+ "category": "workflow",
726
+ "description": "Official backend-served cards for product workflows and runtime contracts.",
727
+ "source": "nexo-desktop-web CardCatalogService",
728
+ "endpoints": [
729
+ "GET /api/cards/catalog",
730
+ "POST /api/cards/match",
731
+ "GET /api/cards/{slug}",
732
+ "POST /api/cards/{slug}/activate",
733
+ ],
734
+ },
735
+ ]
736
+
737
+
613
738
  def _locations() -> dict[str, str]:
614
739
  """Plan Consolidado 0.X.4 — canonical paths for runtime artefacts.
615
740
 
@@ -667,6 +792,7 @@ def build_system_catalog() -> dict:
667
792
  "skills": _skill_entries(),
668
793
  "scripts": _script_entries(),
669
794
  "crons": _cron_entries(),
795
+ "product_capabilities": _product_capability_entries(),
670
796
  "projects": _project_entries(),
671
797
  "artifacts": _artifact_entries(),
672
798
  }
@@ -3,6 +3,9 @@
3
3
  Exposes two MCP tools registered in server.py:
4
4
  - nexo_api_call(method, path, body_json, idempotency_key, headers_json, base_url)
5
5
  - nexo_create_app_token(name, abilities, allowed_platforms, expires_at)
6
+ - nexo_support_ticket_list(status, limit)
7
+ - nexo_support_ticket_read(ticket_id)
8
+ - nexo_support_ticket_create(subject, message, priority)
6
9
 
7
10
  The session bearer (Sanctum personal access token) is stored by NEXO Desktop in
8
11
  the OS keychain at:
@@ -24,6 +27,7 @@ from __future__ import annotations
24
27
 
25
28
  import json
26
29
  from typing import Any
30
+ from urllib.parse import quote, urlencode
27
31
 
28
32
  import keyring
29
33
  import requests
@@ -194,3 +198,45 @@ def handle_create_app_token(
194
198
  path="/api/auth/app-tokens",
195
199
  body_json=json.dumps(payload),
196
200
  )
201
+
202
+
203
+ def handle_support_ticket_list(status: str = "", limit: int = 20) -> str:
204
+ """List real NEXO support tickets for the signed-in Desktop user."""
205
+ query: dict[str, str] = {}
206
+ clean_status = (status or "").strip()
207
+ if clean_status:
208
+ query["status"] = clean_status
209
+ try:
210
+ parsed_limit = max(1, min(100, int(limit or 20)))
211
+ except Exception:
212
+ parsed_limit = 20
213
+ query["limit"] = str(parsed_limit)
214
+ suffix = "?" + urlencode(query) if query else ""
215
+ return handle_api_call("GET", f"/api/support/tickets{suffix}")
216
+
217
+
218
+ def handle_support_ticket_read(ticket_id: str) -> str:
219
+ """Read one real NEXO support ticket by id for the signed-in Desktop user."""
220
+ clean = (ticket_id or "").strip()
221
+ if not clean:
222
+ return "ERROR: ticket_id is required."
223
+ return handle_api_call("GET", f"/api/support/tickets/{quote(clean, safe='')}")
224
+
225
+
226
+ def handle_support_ticket_create(subject: str, message: str, priority: str = "normal") -> str:
227
+ """Create a real NEXO support ticket instead of a private/internal followup."""
228
+ clean_subject = (subject or "").strip()
229
+ clean_message = (message or "").strip()
230
+ clean_priority = (priority or "normal").strip().lower()
231
+ if not clean_subject:
232
+ return "ERROR: subject is required."
233
+ if not clean_message:
234
+ return "ERROR: message is required."
235
+ if clean_priority not in {"low", "normal", "high", "urgent"}:
236
+ clean_priority = "normal"
237
+ payload = {
238
+ "subject": clean_subject,
239
+ "message": clean_message,
240
+ "priority": clean_priority,
241
+ }
242
+ return handle_api_call("POST", "/api/support/tickets", body_json=json.dumps(payload, ensure_ascii=False))
@@ -44,7 +44,7 @@ Claude Code may list `mcp__nexo__*` tools as **deferred** at session start (name
44
44
  - Only continue into deeper investigation before the first visible answer if the user explicitly asked for a deep investigation or the situation is urgent/high-risk.
45
45
  - For single-artifact asks (email, message, diary item, reminder, prior fact), retrieve the artifact, summarize it, then decide whether further action is needed.
46
46
  - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
47
- - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first user-visible answer unless the user explicitly asked for it.
47
+ - After `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first user-visible answer unless the user explicitly asked for it.
48
48
 
49
49
  ## Communication Guardrail
50
50
  - In the first answer to Francisco on any thread, lead with the direct recommendation, decision, or status.
@@ -39,7 +39,7 @@ Codex (and Claude Code) may list `mcp__nexo__*` tools as **deferred** at session
39
39
  - Only keep investigating before the first visible answer if the user explicitly requested deep investigation or the situation is urgent/high-risk.
40
40
  - For single-artifact asks (email, message, diary item, reminder, prior fact), retrieve it, summarize it, then decide if more work is needed.
41
41
  - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
42
- - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first visible answer unless the user explicitly asked for it.
42
+ - After `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first visible answer unless the user explicitly asked for it.
43
43
 
44
44
  ## Communication Guardrail
45
45
  - In the first answer to Francisco on any thread, lead with the direct recommendation, decision, or status.
@@ -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 ==
@@ -60,13 +63,20 @@ This is CRITICAL — without the diary, the next NEXO session loses continuity.
60
63
  CONFIG: [[config_path]] (IMAP/SMTP, port, password)
61
64
  DATABASE: [[email_db_path]] (SQLite, `emails` table)
62
65
 
63
- 1. Connect via IMAP. Detect ALL unread emails in INBOX.
64
- 2. For EACH unread email, ALWAYS use `nexo_email_related(uid, folder='INBOX')`.
65
- It is FORBIDDEN to decide using only `nexo_email_read(uid)` or `nexo_email_thread(uid)`.
66
- `nexo_email_related` returns the full related context as complete threads
67
- (Inbox + Sent), a MERGED TIMELINE in chronological order,
68
- and an aggregated index of RELATED FILES with stored local paths.
69
- If you only need the clean attachment list, use `nexo_email_attachments(uid, folder='INBOX')`.
66
+ 1. Connect via IMAP directly using CONFIG. Detect ALL unread emails in INBOX.
67
+ 2. For EACH assigned `message_id`, resolve the matching IMAP UID by fetching headers
68
+ from INBOX with `BODY.PEEK[HEADER]` and matching the `Message-ID` header exactly.
69
+ Then fetch the complete RFC822 message with `BODY.PEEK[]` (or equivalent full-message fetch)
70
+ and parse the body before making any decision.
71
+ It is FORBIDDEN to decide from the local DB header row alone. The DB row usually contains
72
+ `message_id`, sender and subject only; it is not the email body.
73
+ If the full body cannot be fetched or parsed into text, do NOT reply to the sender.
74
+ Mark the DB row as `error` or `needs_interactive`, add an `email_events` detail explaining
75
+ the fetch/parse failure, and escalate to the operator if the message cannot be safely handled.
76
+ If you need attachments, extract them from the fetched RFC822 MIME parts.
77
+ 2.5. To rebuild related thread context, use direct IMAP searches in INBOX and Sent based on
78
+ `Message-ID`, `In-Reply-To`, `References`, subject, and participants. Fetch the full RFC822
79
+ body for each related message you rely on. Do not use unavailable `nexo_email_*` helpers.
70
80
  3. Treat all related messages as ONE operational context.
71
81
  If email 1 says 'do X' and email 3 later says 'actually do not do it',
72
82
  the LATER instruction wins.
@@ -145,7 +155,7 @@ This guard call must happen before creating or editing the buffer files. Do not
145
155
  When replying, the email MUST include the COMPLETE related history below,
146
156
  not just the immediate thread.
147
157
  Mandatory steps before sending:
148
- 1. Reuse the MERGED TIMELINE from `nexo_email_related(uid)` as the source of truth.
158
+ 1. Reuse the MERGED TIMELINE you rebuilt by direct IMAP full-message fetches as the source of truth.
149
159
  2. Sort it chronologically (oldest first).
150
160
  3. Concatenate it into `/tmp/nexo-thread-UID.txt` with this format for each message:
151
161
  -- From: Name <email>
@@ -162,7 +172,9 @@ Mandatory steps before sending:
162
172
  The bottom of the email must preserve message -> reply -> message -> reply without dropping previous answers.
163
173
 
164
174
  == SEND VIA `nexo-send-reply.py` ==
165
- [[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`).
166
178
 
167
179
  == ANTI-LOOP PROTECTION ==
168
180
  Do not reply to auto-replies, [[agent_email_label]] itself, `noreply@`,
@@ -341,6 +341,45 @@
341
341
  },
342
342
  "triggers_after": []
343
343
  },
344
+ "nexo_support_ticket_create": {
345
+ "description": "Create a real authenticated NEXO support ticket",
346
+ "category": "support",
347
+ "source": "server",
348
+ "requires": [],
349
+ "provides": ["support_ticket"],
350
+ "internal_calls": ["nexo_api_call"],
351
+ "enforcement": {
352
+ "level": "none",
353
+ "rules": []
354
+ },
355
+ "triggers_after": []
356
+ },
357
+ "nexo_support_ticket_list": {
358
+ "description": "List authenticated NEXO support tickets",
359
+ "category": "support",
360
+ "source": "server",
361
+ "requires": [],
362
+ "provides": ["support_ticket_list"],
363
+ "internal_calls": ["nexo_api_call"],
364
+ "enforcement": {
365
+ "level": "none",
366
+ "rules": []
367
+ },
368
+ "triggers_after": []
369
+ },
370
+ "nexo_support_ticket_read": {
371
+ "description": "Read one authenticated NEXO support ticket by ID",
372
+ "category": "support",
373
+ "source": "server",
374
+ "requires": [],
375
+ "provides": ["support_ticket"],
376
+ "internal_calls": ["nexo_api_call"],
377
+ "enforcement": {
378
+ "level": "none",
379
+ "rules": []
380
+ },
381
+ "triggers_after": []
382
+ },
344
383
  "nexo_artifact_create": {
345
384
  "description": "Register artifact (service, dashboard, script)",
346
385
  "category": "artifact",