nexo-brain 7.30.11 → 7.30.14

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.11",
3
+ "version": "7.30.14",
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.11` is the current packaged-runtime line. 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.
21
+ Version `7.30.14` is the current packaged-runtime line. 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.
22
+
23
+ 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`.
24
+
25
+ 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.
26
+
27
+ 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.
22
28
 
23
29
  Previously in `7.30.10`: patch release over v7.30.9 - packaged `nexo update` now stamps the verified repair baseline after import verification, including same-version maintenance runs.
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.11",
3
+ "version": "7.30.14",
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
@@ -21,7 +21,6 @@ from __future__ import annotations
21
21
  import getpass
22
22
  import json
23
23
  import sys
24
- import time
25
24
  from typing import Any
26
25
 
27
26
 
@@ -70,34 +69,20 @@ def _sent_folder_from_account(account: dict | None) -> str:
70
69
  if isinstance(account, dict) and isinstance(account.get("metadata"), dict):
71
70
  metadata = account.get("metadata") or {}
72
71
  value = str(metadata.get("sent_folder") or "").strip()
73
- return value or "INBOX.Sent"
72
+ return value or "Sent"
74
73
 
75
74
 
76
75
  def _store_credential(service: str, key: str, value: str) -> None:
77
- """Write password to the `credentials` table (simple cleartext by
78
- default upgrading to keychain is a v7 follow-up). Never echo the
79
- password back to stdout."""
80
- from db._core import get_db
81
- conn = get_db()
82
- now = time.time()
83
- conn.execute(
84
- """
85
- INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
86
- VALUES (?, ?, ?, 'email account password (nexo email setup)', ?, ?)
87
- ON CONFLICT(service, key) DO UPDATE SET
88
- value = excluded.value,
89
- updated_at = excluded.updated_at
90
- """,
91
- (service, key, value, now, now),
92
- )
93
- conn.commit()
76
+ """Write password without echoing it back to stdout."""
77
+ from email_credentials import store_email_credential
78
+
79
+ store_email_credential(service, key, value, "email account password (nexo email setup)")
94
80
 
95
81
 
96
82
  def _delete_credential(service: str, key: str) -> None:
97
- from db._core import get_db
98
- conn = get_db()
99
- conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
100
- conn.commit()
83
+ from email_credentials import delete_email_credential
84
+
85
+ delete_email_credential(service, key)
101
86
 
102
87
 
103
88
  def cmd_email_setup(args) -> int:
@@ -154,7 +139,7 @@ def cmd_email_setup(args) -> int:
154
139
  "",
155
140
  )
156
141
  trusted = [d.strip() for d in trusted_raw.split(",") if d.strip()]
157
- sent_folder = _prompt("IMAP sent folder", "INBOX.Sent").strip() or "INBOX.Sent"
142
+ sent_folder = _prompt("IMAP sent folder", "Sent").strip() or "Sent"
158
143
 
159
144
  role = _prompt(
160
145
  "Account role: inbox (read only) / outbox (send only) / both",
@@ -727,7 +712,7 @@ def register_email_parser(subparsers) -> None:
727
712
  s.add_argument("--operator", dest="operator", default="")
728
713
  s.add_argument("--trusted-domains", dest="trusted_domains", default="")
729
714
  s.add_argument("--sent-folder", dest="sent_folder", default=None,
730
- help="IMAP folder where sent copies should be appended (default: INBOX.Sent).")
715
+ help="IMAP folder where sent copies should be appended (default: Sent).")
731
716
  s.add_argument("--role", dest="role", default="both", choices=["inbox", "outbox", "both"])
732
717
  read_group = s.add_mutually_exclusive_group()
733
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",
@@ -41,24 +41,11 @@ LEGACY_CONFIG_PATH = _legacy_config_path()
41
41
 
42
42
 
43
43
  def _get_credential(service: str, key: str) -> str:
44
- """Fetch a password from the credentials table. Returns empty string
45
- on any miss so the caller can log-and-skip instead of crashing a cron.
46
- """
47
- if not service or not key:
48
- return ""
44
+ """Fetch a password from keyring marker or legacy credentials table."""
49
45
  try:
50
- from db._core import get_db
51
- except Exception: # pragma: no cover
52
- return ""
53
- try:
54
- conn = get_db()
55
- row = conn.execute(
56
- "SELECT value FROM credentials WHERE service = ? AND key = ?",
57
- (service, key),
58
- ).fetchone()
59
- if row is None:
60
- return ""
61
- return str(row[0] or "")
46
+ from email_credentials import read_email_credential
47
+
48
+ return read_email_credential(service, key)
62
49
  except Exception as exc: # pragma: no cover
63
50
  _logger.warning("credential lookup failed for %s/%s: %s", service, key, exc)
64
51
  return ""
@@ -70,7 +57,7 @@ def _account_to_runtime_account(account: dict) -> dict:
70
57
  account.get("credential_key", ""),
71
58
  )
72
59
  metadata = account.get("metadata") if isinstance(account.get("metadata"), dict) else {}
73
- sent_folder = str(metadata.get("sent_folder") or "").strip() or "INBOX.Sent"
60
+ sent_folder = str(metadata.get("sent_folder") or "").strip() or "Sent"
74
61
  return {
75
62
  "label": account.get("label", ""),
76
63
  "email": account.get("email", ""),
@@ -0,0 +1,159 @@
1
+ """Credential storage for email account passwords.
2
+
3
+ Email account rows keep only ``credential_service`` + ``credential_key``.
4
+ Historically the referenced ``credentials.value`` column stored the password
5
+ directly. New writes try to place the secret in the OS keyring and keep a
6
+ ``keyring://...`` marker in SQLite. Legacy plaintext values remain readable so
7
+ older installs keep working until they are rotated or migrated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import os
14
+ import time
15
+ from urllib.parse import quote, unquote
16
+
17
+ KEYRING_SERVICE = "com.nexo.email"
18
+ KEYRING_MARKER_PREFIX = "keyring://"
19
+
20
+
21
+ def _db():
22
+ from db._core import get_db
23
+
24
+ return get_db()
25
+
26
+
27
+ def _marker(service: str, key: str) -> str:
28
+ return f"{KEYRING_MARKER_PREFIX}{quote(service, safe='')}/{quote(key, safe='')}"
29
+
30
+
31
+ def _parse_marker(value: str) -> tuple[str, str] | None:
32
+ if not value.startswith(KEYRING_MARKER_PREFIX):
33
+ return None
34
+ rest = value[len(KEYRING_MARKER_PREFIX):]
35
+ if "/" not in rest:
36
+ return None
37
+ service, key = rest.split("/", 1)
38
+ return unquote(service), unquote(key)
39
+
40
+
41
+ def _account_name(service: str, key: str) -> str:
42
+ return f"{service}:{key}"
43
+
44
+
45
+ def _keyring_module():
46
+ try:
47
+ return importlib.import_module("keyring")
48
+ except Exception:
49
+ return None
50
+
51
+
52
+ def _read_stored_value(service: str, key: str) -> str:
53
+ if not service or not key:
54
+ return ""
55
+ row = _db().execute(
56
+ "SELECT value FROM credentials WHERE service = ? AND key = ?",
57
+ (service, key),
58
+ ).fetchone()
59
+ if row is None:
60
+ return ""
61
+ return str(row[0] or "")
62
+
63
+
64
+ def _write_db_value(service: str, key: str, value: str, notes: str) -> None:
65
+ conn = _db()
66
+ now = time.time()
67
+ conn.execute(
68
+ """
69
+ INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
70
+ VALUES (?, ?, ?, ?, ?, ?)
71
+ ON CONFLICT(service, key) DO UPDATE SET
72
+ value = excluded.value,
73
+ notes = excluded.notes,
74
+ updated_at = excluded.updated_at
75
+ """,
76
+ (service, key, value, notes, now, now),
77
+ )
78
+ conn.commit()
79
+
80
+
81
+ def store_email_credential(service: str, key: str, value: str, notes: str = "email account password") -> str:
82
+ """Store an email password and return the SQLite value written.
83
+
84
+ Uses keyring when available. If the keyring backend is unavailable or
85
+ locked, the legacy SQLite plaintext path is used so existing automation
86
+ does not lose email access.
87
+ """
88
+ service = str(service or "").strip()
89
+ key = str(key or "").strip()
90
+ value = str(value or "")
91
+ if not service or not key:
92
+ return ""
93
+
94
+ mode = os.environ.get("NEXO_EMAIL_CREDENTIAL_STORE", "auto").strip().lower()
95
+ keyring = None if mode in {"sqlite", "legacy", "plain", "plaintext"} else _keyring_module()
96
+ if keyring is not None:
97
+ try:
98
+ keyring.set_password(KEYRING_SERVICE, _account_name(service, key), value)
99
+ stored = _marker(service, key)
100
+ _write_db_value(service, key, stored, notes + " (stored in system keyring)")
101
+ return stored
102
+ except Exception:
103
+ pass
104
+
105
+ if mode == "keyring":
106
+ return ""
107
+
108
+ _write_db_value(service, key, value, notes + " (legacy sqlite fallback)")
109
+ return value
110
+
111
+
112
+ def read_email_credential(service: str, key: str) -> str:
113
+ """Resolve an email password from keyring marker or legacy plaintext."""
114
+ stored = _read_stored_value(str(service or "").strip(), str(key or "").strip())
115
+ parsed = _parse_marker(stored)
116
+ if parsed is None:
117
+ return stored
118
+
119
+ keyring = _keyring_module()
120
+ if keyring is None:
121
+ return ""
122
+ try:
123
+ value = keyring.get_password(KEYRING_SERVICE, _account_name(*parsed))
124
+ except Exception:
125
+ return ""
126
+ return str(value or "")
127
+
128
+
129
+ def delete_email_credential(service: str, key: str) -> None:
130
+ service = str(service or "").strip()
131
+ key = str(key or "").strip()
132
+ if not service or not key:
133
+ return
134
+
135
+ parsed = _parse_marker(_read_stored_value(service, key))
136
+ keyring = _keyring_module()
137
+ if parsed is not None and keyring is not None:
138
+ try:
139
+ keyring.delete_password(KEYRING_SERVICE, _account_name(*parsed))
140
+ except Exception:
141
+ pass
142
+
143
+ conn = _db()
144
+ conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
145
+ conn.commit()
146
+
147
+
148
+ def is_keyring_marker(value: str) -> bool:
149
+ return _parse_marker(str(value or "")) is not None
150
+
151
+
152
+ __all__ = [
153
+ "KEYRING_MARKER_PREFIX",
154
+ "KEYRING_SERVICE",
155
+ "delete_email_credential",
156
+ "is_keyring_marker",
157
+ "read_email_credential",
158
+ "store_email_credential",
159
+ ]
@@ -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:
@@ -26,7 +26,6 @@ import json
26
26
  import os
27
27
  import re
28
28
  import sys
29
- import time
30
29
  from pathlib import Path
31
30
 
32
31
 
@@ -107,7 +106,7 @@ def main(argv: list[str]) -> int:
107
106
  "claude_binary": legacy.get("claude_binary", ""),
108
107
  "working_dir": legacy.get("working_dir", str(Path.home())),
109
108
  "max_process_time": legacy.get("max_process_time"),
110
- "sent_folder": legacy.get("sent_folder", "INBOX.Sent"),
109
+ "sent_folder": legacy.get("sent_folder", "Sent"),
111
110
  "operator_aliases": legacy_operator_aliases,
112
111
  }
113
112
 
@@ -121,20 +120,14 @@ def main(argv: list[str]) -> int:
121
120
  print(f"[dry-run] credentials[{cred_service}/{cred_key}] = <password>")
122
121
  return 0
123
122
 
124
- # Store credential
125
- from db._core import get_db
126
- conn = get_db()
127
- now = time.time()
128
- conn.execute(
129
- """
130
- INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
131
- VALUES (?, ?, ?, 'migrated from ~/.nexo/nexo-email/config.json (F1)', ?, ?)
132
- ON CONFLICT(service, key) DO UPDATE SET
133
- value = excluded.value, updated_at = excluded.updated_at
134
- """,
135
- (cred_service, cred_key, password, now, now),
123
+ from email_credentials import store_email_credential
124
+
125
+ store_email_credential(
126
+ cred_service,
127
+ cred_key,
128
+ password,
129
+ "migrated from ~/.nexo/nexo-email/config.json (F1)",
136
130
  )
137
- conn.commit()
138
131
 
139
132
  if existing and not args.force:
140
133
  existing_metadata = existing.get("metadata") if isinstance(existing.get("metadata"), dict) else {}
@@ -147,7 +140,7 @@ def main(argv: list[str]) -> int:
147
140
  **metadata,
148
141
  **existing_metadata,
149
142
  "operator_aliases": merged_aliases,
150
- "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"),
151
144
  }
152
145
  normalized_role = str(existing.get("role") or "both")
153
146
  account = add_email_account(
@@ -194,7 +187,7 @@ def main(argv: list[str]) -> int:
194
187
  can_send=True,
195
188
  )
196
189
  print(f"✓ Cuenta agente '{args.label}' migrada ({account.get('email')}).")
197
- print(f" Password guardada en credentials[{cred_service}/{cred_key}].")
190
+ print(f" Password guardada en el almacen de credenciales[{cred_service}/{cred_key}].")
198
191
  print(
199
192
  " Metadata: "
200
193
  f"operator_aliases={len(legacy_operator_aliases)}, trusted_domains={len(trusted)}."
@@ -1172,7 +1172,7 @@ def count_stuck_emails():
1172
1172
 
1173
1173
  # === Email-loss prevention (2026-04-14): pre-register + orphan recovery ===
1174
1174
  # 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
1175
+ # marked SEEN (from an IMAP full-message read) but BD has no row. count_stuck_emails
1176
1176
  # cannot see it, and has_new_email returns 0. The email is lost.
1177
1177
  # Fix:
1178
1178
  # 1. Pre-register: INSERT status='pending' rows BEFORE launching NEXO, using
@@ -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())
@@ -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"])
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.
@@ -60,13 +60,20 @@ This is CRITICAL — without the diary, the next NEXO session loses continuity.
60
60
  CONFIG: [[config_path]] (IMAP/SMTP, port, password)
61
61
  DATABASE: [[email_db_path]] (SQLite, `emails` table)
62
62
 
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')`.
63
+ 1. Connect via IMAP directly using CONFIG. Detect ALL unread emails in INBOX.
64
+ 2. For EACH assigned `message_id`, resolve the matching IMAP UID by fetching headers
65
+ from INBOX with `BODY.PEEK[HEADER]` and matching the `Message-ID` header exactly.
66
+ Then fetch the complete RFC822 message with `BODY.PEEK[]` (or equivalent full-message fetch)
67
+ and parse the body before making any decision.
68
+ It is FORBIDDEN to decide from the local DB header row alone. The DB row usually contains
69
+ `message_id`, sender and subject only; it is not the email body.
70
+ If the full body cannot be fetched or parsed into text, do NOT reply to the sender.
71
+ Mark the DB row as `error` or `needs_interactive`, add an `email_events` detail explaining
72
+ the fetch/parse failure, and escalate to the operator if the message cannot be safely handled.
73
+ If you need attachments, extract them from the fetched RFC822 MIME parts.
74
+ 2.5. To rebuild related thread context, use direct IMAP searches in INBOX and Sent based on
75
+ `Message-ID`, `In-Reply-To`, `References`, subject, and participants. Fetch the full RFC822
76
+ body for each related message you rely on. Do not use unavailable `nexo_email_*` helpers.
70
77
  3. Treat all related messages as ONE operational context.
71
78
  If email 1 says 'do X' and email 3 later says 'actually do not do it',
72
79
  the LATER instruction wins.
@@ -145,7 +152,7 @@ This guard call must happen before creating or editing the buffer files. Do not
145
152
  When replying, the email MUST include the COMPLETE related history below,
146
153
  not just the immediate thread.
147
154
  Mandatory steps before sending:
148
- 1. Reuse the MERGED TIMELINE from `nexo_email_related(uid)` as the source of truth.
155
+ 1. Reuse the MERGED TIMELINE you rebuilt by direct IMAP full-message fetches as the source of truth.
149
156
  2. Sort it chronologically (oldest first).
150
157
  3. Concatenate it into `/tmp/nexo-thread-UID.txt` with this format for each message:
151
158
  -- From: Name <email>
@@ -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",