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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/cli_email.py +10 -25
- package/src/crons/manifest.json +14 -0
- package/src/email_config.py +5 -18
- package/src/email_credentials.py +159 -0
- package/src/enforcement_engine.py +13 -0
- package/src/retroactive_learnings.py +33 -11
- package/src/scripts/nexo-daily-self-audit.py +39 -2
- package/src/scripts/nexo-email-migrate-config.py +10 -17
- package/src/scripts/nexo-email-monitor.py +1 -1
- package/src/scripts/nexo-memory-observation-watchdog.py +125 -0
- package/src/scripts/nexo-send-reply.py +1 -1
- package/src/server.py +29 -1
- package/src/system_catalog.py +126 -0
- package/src/tools_api_call.py +46 -0
- package/templates/CLAUDE.md.template +1 -1
- package/templates/CODEX.AGENTS.md.template +1 -1
- package/templates/core-prompts/email-monitor.md +15 -8
- package/tool-enforcement-map.json +39 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
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.
|
|
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.
|
|
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 "
|
|
72
|
+
return value or "Sent"
|
|
74
73
|
|
|
75
74
|
|
|
76
75
|
def _store_credential(service: str, key: str, value: str) -> None:
|
|
77
|
-
"""Write password
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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", "
|
|
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:
|
|
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)
|
package/src/crons/manifest.json
CHANGED
|
@@ -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",
|
package/src/email_config.py
CHANGED
|
@@ -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
|
|
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
|
|
51
|
-
|
|
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 "
|
|
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 (
|
|
339
|
-
|
|
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"
|
|
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"] >=
|
|
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", "
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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 "
|
|
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
|
|
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,
|
package/src/system_catalog.py
CHANGED
|
@@ -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
|
}
|
package/src/tools_api_call.py
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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",
|