nexo-brain 7.37.3 → 7.37.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/managed_mcp/lock.json +3 -3
- package/src/pre_answer_router.py +12 -8
- package/src/scripts/deep-sleep/apply_findings.py +107 -0
- package/src/scripts/deep-sleep/synthesize.py +50 -3
- package/src/scripts/nexo-daily-self-audit.py +202 -3
- package/src/scripts/nexo-morning-agent.py +160 -2
- package/src/server.py +29 -2
- package/src/tools_api_call.py +66 -8
- package/templates/core-prompts/morning-agent.md +4 -0
- package/tool-enforcement-map.json +39 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.37.
|
|
3
|
+
"version": "7.37.4",
|
|
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,9 @@
|
|
|
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.37.
|
|
21
|
+
Version `7.37.4` is the current packaged-runtime line. Patch release over v7.37.3 - product-gap reporting, stale briefing noise, and opportunity loops: Deep Sleep reports recurring NEXO product gaps through sanitized Desktop support tickets, self-audit preserves closed internal opportunities, the morning briefing reads item history before resurfacing decisions, and support-ticket tools match the live backend contract.
|
|
22
|
+
|
|
23
|
+
Previously in `7.37.3`: patch release over v7.37.2 - release pipeline timeout hardening: the Brain publish workflow keeps the full pre-publish pytest gate, but now gives slow GitHub runners enough time to finish the suite and release readiness before public-channel publication.
|
|
22
24
|
|
|
23
25
|
Previously in `7.37.2`: patch release over v7.37.1 - runtime shutdown and CI stability: session keepalive writers now stop before the shared SQLite connection closes, SQLite close is serialized under the write lock, and the full Brain test workflow has enough time to finish instead of cancelling slow-but-valid runs. The v7.37.2 tag attempt did not publish npm/GitHub release artifacts; v7.37.3 is the public line.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.37.
|
|
3
|
+
"version": "7.37.4",
|
|
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",
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
"chrome-devtools-mcp": {
|
|
7
7
|
"source_type": "npm",
|
|
8
8
|
"package": "chrome-devtools-mcp",
|
|
9
|
-
"version": "1.
|
|
10
|
-
"integrity": "sha512-
|
|
11
|
-
"tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.
|
|
9
|
+
"version": "1.3.0",
|
|
10
|
+
"integrity": "sha512-52NVUwWSL4eW7W9nsDrzYJF96IKVuxEwAn4O7ZfdNRtopS954P9nryJbdYwg7vdqxhLrvioGFlm5e4P41WXsiw==",
|
|
11
|
+
"tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.3.0.tgz",
|
|
12
12
|
"bin": "chrome-devtools-mcp",
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
package/src/pre_answer_router.py
CHANGED
|
@@ -41,6 +41,7 @@ EVIDENCE_REQUIRED_INTENTS = {"live_state_claim"}
|
|
|
41
41
|
|
|
42
42
|
DEFAULT_BUDGET_MS = 2500
|
|
43
43
|
DEFAULT_TOKEN_BUDGET = 2500
|
|
44
|
+
STRONG_TRANSCRIPT_INDEX_MATCH = 0.75
|
|
44
45
|
MAX_SOURCE_WORKERS = int(os.environ.get("NEXO_PRE_ANSWER_SOURCE_WORKERS", "6") or "6")
|
|
45
46
|
PRE_ANSWER_SEMANTIC_DECISION_KIND = "pre_answer_intent"
|
|
46
47
|
|
|
@@ -1989,20 +1990,23 @@ def _source_transcripts(request: SourceRequest) -> SourceResult:
|
|
|
1989
1990
|
query_tokens = _tokenize(request.query)
|
|
1990
1991
|
indexed_rows: list[dict[str, Any]] = []
|
|
1991
1992
|
for row in rows:
|
|
1992
|
-
modified = str(row.get("modified_at") or "")
|
|
1993
|
-
if modified:
|
|
1994
|
-
try:
|
|
1995
|
-
if datetime.fromisoformat(modified) < cutoff:
|
|
1996
|
-
continue
|
|
1997
|
-
except Exception:
|
|
1998
|
-
pass
|
|
1999
1993
|
haystack = " ".join(
|
|
2000
1994
|
str(row.get(field) or "")
|
|
2001
1995
|
for field in ("sanitized_summary", "display_name", "session_id", "conversation_id", "path_ref", "metadata_json")
|
|
2002
1996
|
)
|
|
2003
1997
|
score = _score_text_match(query_tokens, haystack) if query_tokens else 0.0
|
|
2004
|
-
|
|
1998
|
+
ref_matches = _row_ref_matches(request.query, row)
|
|
1999
|
+
if ref_matches:
|
|
2005
2000
|
score = max(score, 2.0)
|
|
2001
|
+
modified = str(row.get("modified_at") or "")
|
|
2002
|
+
stale = False
|
|
2003
|
+
if modified:
|
|
2004
|
+
try:
|
|
2005
|
+
stale = datetime.fromisoformat(modified) < cutoff
|
|
2006
|
+
except Exception:
|
|
2007
|
+
pass
|
|
2008
|
+
if stale and not ref_matches and score < STRONG_TRANSCRIPT_INDEX_MATCH:
|
|
2009
|
+
continue
|
|
2006
2010
|
if score <= 0:
|
|
2007
2011
|
continue
|
|
2008
2012
|
row["_score"] = round(score, 4)
|
|
@@ -129,6 +129,108 @@ CONTRADICTION_PAIRS = (
|
|
|
129
129
|
)
|
|
130
130
|
TABLE_COLUMNS_CACHE: dict[tuple[str, str], set[str]] = {}
|
|
131
131
|
|
|
132
|
+
_EMAIL_RE = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
|
|
133
|
+
_URL_RE = re.compile(r"\bhttps?://[^\s<>'\"]+", re.IGNORECASE)
|
|
134
|
+
_MAC_PATH_RE = re.compile(r"(?<!\w)/(?:Users|Volumes|private|tmp|var)/[^\s<>'\"]+")
|
|
135
|
+
_WIN_PATH_RE = re.compile(r"\b[A-Za-z]:\\(?:Users|ProgramData|Windows|Temp)\\[^\s<>'\"]+")
|
|
136
|
+
_SECRET_ASSIGNMENT_RE = re.compile(
|
|
137
|
+
r"(?i)\b(api[_-]?key|authorization|bearer|cookie|credential|password|secret|session|token)\b\s*[:=]\s*[^\s,;]+"
|
|
138
|
+
)
|
|
139
|
+
_SECRET_VALUE_RE = re.compile(
|
|
140
|
+
r"\b(?:sk-[A-Za-z0-9_-]{12,}|pk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|AKIA[0-9A-Z]{12,})\b"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sanitize_product_gap_text(value: object, *, limit: int = 600) -> str:
|
|
145
|
+
"""Redact tenant/operator data before sending product-gap reports to Desktop."""
|
|
146
|
+
text = str(value or "")
|
|
147
|
+
home = str(Path.home())
|
|
148
|
+
if home:
|
|
149
|
+
text = text.replace(home, "[redacted-home]")
|
|
150
|
+
for raw in (str(NEXO_HOME), str(NEXO_CODE)):
|
|
151
|
+
if raw:
|
|
152
|
+
text = text.replace(raw, "[redacted-path]")
|
|
153
|
+
text = _EMAIL_RE.sub("[redacted-email]", text)
|
|
154
|
+
text = _URL_RE.sub("[redacted-url]", text)
|
|
155
|
+
text = _SECRET_ASSIGNMENT_RE.sub(lambda m: f"{m.group(1)}=[redacted-secret]", text)
|
|
156
|
+
text = _SECRET_VALUE_RE.sub("[redacted-secret]", text)
|
|
157
|
+
text = _MAC_PATH_RE.sub("[redacted-path]", text)
|
|
158
|
+
text = _WIN_PATH_RE.sub("[redacted-path]", text)
|
|
159
|
+
text = re.sub(r"\b(?:AGENTS|CLAUDE|MEMORY)\.md\b", "[redacted-bootstrap]", text)
|
|
160
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
161
|
+
if limit > 0 and len(text) > limit:
|
|
162
|
+
return text[: max(0, limit - 3)].rstrip() + "..."
|
|
163
|
+
return text
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _product_gap_evidence_examples(action: dict, limit: int = 3) -> list[str]:
|
|
167
|
+
examples: list[str] = []
|
|
168
|
+
for entry in action.get("evidence", []) or []:
|
|
169
|
+
if isinstance(entry, dict):
|
|
170
|
+
raw = entry.get("quote") or entry.get("text") or entry.get("summary") or entry.get("evidence") or entry
|
|
171
|
+
else:
|
|
172
|
+
raw = entry
|
|
173
|
+
cleaned = sanitize_product_gap_text(raw, limit=220)
|
|
174
|
+
if cleaned:
|
|
175
|
+
examples.append(cleaned)
|
|
176
|
+
if len(examples) >= limit:
|
|
177
|
+
break
|
|
178
|
+
return examples
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_product_gap_report(action: dict, content: dict, dedupe_key: str) -> dict:
|
|
182
|
+
"""Create a sanitized NEXO Desktop support ticket for a recurring product gap."""
|
|
183
|
+
try:
|
|
184
|
+
from tools_api_call import handle_support_ticket_create
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
return {"success": False, "error": f"support ticket API unavailable: {exc}"}
|
|
187
|
+
|
|
188
|
+
title = sanitize_product_gap_text(content.get("title") or "Deep Sleep product gap", limit=140)
|
|
189
|
+
description = sanitize_product_gap_text(content.get("description") or title, limit=900)
|
|
190
|
+
pattern = sanitize_product_gap_text(content.get("pattern") or "", limit=500)
|
|
191
|
+
deliverable = sanitize_product_gap_text(content.get("deliverable") or "", limit=120)
|
|
192
|
+
sessions_count = content.get("sessions_count", "")
|
|
193
|
+
evidence_count = content.get("evidence_count", len(action.get("evidence", []) or []))
|
|
194
|
+
impact = sanitize_product_gap_text(action.get("impact") or "", limit=80)
|
|
195
|
+
confidence = action.get("confidence", "")
|
|
196
|
+
examples = _product_gap_evidence_examples(action)
|
|
197
|
+
|
|
198
|
+
lines = [
|
|
199
|
+
"Deep Sleep detected a recurring NEXO product gap that should be reviewed by the product team.",
|
|
200
|
+
"",
|
|
201
|
+
f"Impact: {impact or 'unspecified'}",
|
|
202
|
+
f"Confidence: {confidence}",
|
|
203
|
+
f"Suggested deliverable: {deliverable or 'product improvement'}",
|
|
204
|
+
f"Observed sessions: {sessions_count or 'unknown'}",
|
|
205
|
+
f"Evidence items: {evidence_count or 0}",
|
|
206
|
+
"",
|
|
207
|
+
f"Pattern: {pattern or 'No compact pattern supplied.'}",
|
|
208
|
+
f"Requested behavior: {description}",
|
|
209
|
+
"",
|
|
210
|
+
"Privacy: operator/client-specific paths, URLs, emails, bootstrap filenames and secret-looking values were redacted before sending.",
|
|
211
|
+
]
|
|
212
|
+
if examples:
|
|
213
|
+
lines.extend(["", "Redacted examples:"])
|
|
214
|
+
lines.extend(f"- {example}" for example in examples)
|
|
215
|
+
|
|
216
|
+
client_message_id = sanitize_product_gap_text(dedupe_key or "", limit=120) or (
|
|
217
|
+
"product-gap-" + hashlib.md5(description.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
|
|
218
|
+
)
|
|
219
|
+
priority = "high" if str(action.get("impact") or "").lower() in {"high", "critical"} else "normal"
|
|
220
|
+
response = handle_support_ticket_create(
|
|
221
|
+
f"[NEXO-PRODUCT-GAP] {title}",
|
|
222
|
+
"\n".join(lines),
|
|
223
|
+
priority=priority,
|
|
224
|
+
client_message_id=client_message_id,
|
|
225
|
+
origin="auto_incident",
|
|
226
|
+
)
|
|
227
|
+
return {
|
|
228
|
+
"success": str(response).startswith("HTTP 2") or str(response).startswith("HTTP 201"),
|
|
229
|
+
"response": response,
|
|
230
|
+
"client_message_id": client_message_id,
|
|
231
|
+
"sanitized": True,
|
|
232
|
+
}
|
|
233
|
+
|
|
132
234
|
|
|
133
235
|
def generate_run_id(target_date: str) -> str:
|
|
134
236
|
"""Generate a unique run ID for this execution."""
|
|
@@ -2263,6 +2365,11 @@ def apply_action(action: dict, run_id: str) -> dict:
|
|
|
2263
2365
|
log_entry["status"] = "applied" if result.get("success") else "error"
|
|
2264
2366
|
log_entry["details"] = result
|
|
2265
2367
|
|
|
2368
|
+
elif action_type == "product_gap_report":
|
|
2369
|
+
result = create_product_gap_report(action, content, dedupe_key)
|
|
2370
|
+
log_entry["status"] = "applied" if result.get("success") else "error"
|
|
2371
|
+
log_entry["details"] = result
|
|
2372
|
+
|
|
2266
2373
|
elif action_type == "skill_create":
|
|
2267
2374
|
result = create_skill(content)
|
|
2268
2375
|
log_entry["status"] = "applied" if result.get("success") else "error"
|
|
@@ -40,6 +40,16 @@ DEEP_SLEEP_DIR = paths.operations_dir() / "deep-sleep"
|
|
|
40
40
|
|
|
41
41
|
CLAUDE_TIMEOUT = AUTOMATION_SUBPROCESS_TIMEOUT
|
|
42
42
|
ACTION_VERBS = {"add", "implement", "create", "write", "build", "enforce", "automate", "validate", "guard", "fix", "review"}
|
|
43
|
+
PRODUCT_GAP_DELIVERABLES = {
|
|
44
|
+
"automation",
|
|
45
|
+
"guard",
|
|
46
|
+
"guardrail",
|
|
47
|
+
"hook",
|
|
48
|
+
"script",
|
|
49
|
+
"skill",
|
|
50
|
+
"tool",
|
|
51
|
+
"workflow",
|
|
52
|
+
}
|
|
43
53
|
|
|
44
54
|
|
|
45
55
|
def extract_json_from_response(text: str) -> dict | None:
|
|
@@ -107,6 +117,19 @@ def _looks_concrete_action(text: str) -> bool:
|
|
|
107
117
|
return bool(words & ACTION_VERBS)
|
|
108
118
|
|
|
109
119
|
|
|
120
|
+
def _looks_product_gap_deliverable(value: str) -> bool:
|
|
121
|
+
words = {
|
|
122
|
+
word.strip(".,:;()[]{}").lower()
|
|
123
|
+
for word in str(value or "").replace("/", " ").replace("-", " ").split()
|
|
124
|
+
}
|
|
125
|
+
return bool(words & PRODUCT_GAP_DELIVERABLES)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _product_gap_fingerprint(*parts: str) -> str:
|
|
129
|
+
normalized = _normalize_action_text(" ".join(str(part or "") for part in parts))
|
|
130
|
+
return hashlib.md5(normalized.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
|
|
131
|
+
|
|
132
|
+
|
|
110
133
|
def _pattern_followup_from_fix(pattern: dict) -> dict | None:
|
|
111
134
|
severity = str(pattern.get("severity", "") or "").lower()
|
|
112
135
|
sessions = pattern.get("sessions", []) or []
|
|
@@ -141,13 +164,37 @@ def _pattern_followup_from_fix(pattern: dict) -> dict | None:
|
|
|
141
164
|
if not _looks_concrete_action(followup_description):
|
|
142
165
|
followup_description = f"Implement this fix: {followup_description}"
|
|
143
166
|
|
|
167
|
+
confidence = round(max(float(proposed_fix.get("confidence", 0.0) or 0.0), 0.86 if severity == "high" else 0.78), 2)
|
|
168
|
+
impact = "high" if severity == "high" else "medium"
|
|
169
|
+
evidence = pattern.get("evidence", []) or []
|
|
170
|
+
if _looks_product_gap_deliverable(deliverable):
|
|
171
|
+
fingerprint = _product_gap_fingerprint(pattern_text, title, description, deliverable)
|
|
172
|
+
return {
|
|
173
|
+
"action_type": "product_gap_report",
|
|
174
|
+
"action_class": "auto_apply",
|
|
175
|
+
"confidence": confidence,
|
|
176
|
+
"impact": impact,
|
|
177
|
+
"reversibility": "reversible",
|
|
178
|
+
"evidence": evidence,
|
|
179
|
+
"dedupe_key": f"product-gap:{fingerprint}",
|
|
180
|
+
"content": {
|
|
181
|
+
"title": title or f"Product gap for: {pattern_text[:90]}",
|
|
182
|
+
"description": followup_description,
|
|
183
|
+
"deliverable": deliverable,
|
|
184
|
+
"pattern": pattern_text,
|
|
185
|
+
"sessions_count": len(sessions),
|
|
186
|
+
"evidence_count": len(evidence),
|
|
187
|
+
"reasoning": f"Deep Sleep product gap from recurring pattern: {pattern_text}",
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
return {
|
|
145
192
|
"action_type": "followup_create",
|
|
146
193
|
"action_class": "auto_apply" if severity == "high" else "draft_for_morning",
|
|
147
|
-
"confidence":
|
|
148
|
-
"impact":
|
|
194
|
+
"confidence": confidence,
|
|
195
|
+
"impact": impact,
|
|
149
196
|
"reversibility": "reversible",
|
|
150
|
-
"evidence":
|
|
197
|
+
"evidence": evidence,
|
|
151
198
|
# Content fingerprint, not security-sensitive.
|
|
152
199
|
"dedupe_key": "engineering-fix:" + hashlib.md5(
|
|
153
200
|
_normalize_action_text(followup_description).encode("utf-8"),
|
|
@@ -294,6 +294,16 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
|
|
|
294
294
|
(followup_id,),
|
|
295
295
|
).fetchone()
|
|
296
296
|
if existing_id_row:
|
|
297
|
+
closed_status = str(existing_id_row["status"] or "").upper()
|
|
298
|
+
if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
|
|
299
|
+
conn.commit()
|
|
300
|
+
nexo_db.add_followup_note(
|
|
301
|
+
followup_id,
|
|
302
|
+
"Daily self-audit saw this canonical opportunity again and preserved its closed/non-operational status.",
|
|
303
|
+
actor="self-audit",
|
|
304
|
+
)
|
|
305
|
+
return followup_id
|
|
306
|
+
|
|
297
307
|
update_fields = {
|
|
298
308
|
"description": description,
|
|
299
309
|
"verification": verification,
|
|
@@ -305,9 +315,6 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
|
|
|
305
315
|
update_fields["internal"] = int(bool(internal))
|
|
306
316
|
if "owner" in columns:
|
|
307
317
|
update_fields["owner"] = owner
|
|
308
|
-
closed_status = str(existing_id_row["status"] or "").upper()
|
|
309
|
-
if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
|
|
310
|
-
update_fields["status"] = "PENDING"
|
|
311
318
|
conn.commit()
|
|
312
319
|
result = nexo_db.update_followup(
|
|
313
320
|
followup_id,
|
|
@@ -797,6 +804,23 @@ TOPIC_STOPWORDS = {
|
|
|
797
804
|
"prepare", "finish", "open", "another", "around", "must",
|
|
798
805
|
}
|
|
799
806
|
|
|
807
|
+
OPPORTUNITY_DRAIN_LIMIT = int(os.environ.get("NEXO_SELFAUDIT_OPPORTUNITY_DRAIN_LIMIT", "500"))
|
|
808
|
+
OPPORTUNITY_DRAIN_STOPWORDS = TOPIC_STOPWORDS | {
|
|
809
|
+
"extract", "reusable", "automation", "automations", "automated", "manual",
|
|
810
|
+
"pattern", "patterns", "successful", "protocol", "tasks", "days", "seen",
|
|
811
|
+
"operator", "time", "skill", "skills", "script", "scripts", "workflow",
|
|
812
|
+
"workflows", "candidate", "opportunity", "opportunities", "around",
|
|
813
|
+
}
|
|
814
|
+
OPPORTUNITY_HISTORY_COVERAGE_RE = re.compile(
|
|
815
|
+
r"\b("
|
|
816
|
+
r"already covered|covered by|coverage exists|automation exists|skill exists|script exists|"
|
|
817
|
+
r"ya cubiert[oa]s?|cubiert[oa]s? por|automatizad[oa]s?|"
|
|
818
|
+
r"automatizaci[oó]n(?:es)? existente(?:s)?|skills? existente(?:s)?|"
|
|
819
|
+
r"scripts? existente(?:s)?|workflows? existente(?:s)?|resuelt[oa]s?|resolved|"
|
|
820
|
+
r"no actionable|no accionable|suppressed|suprimid[oa]s?"
|
|
821
|
+
r")\b",
|
|
822
|
+
re.IGNORECASE,
|
|
823
|
+
)
|
|
800
824
|
|
|
801
825
|
def _topic_signature(text: str) -> str:
|
|
802
826
|
tokens = [
|
|
@@ -806,6 +830,156 @@ def _topic_signature(text: str) -> str:
|
|
|
806
830
|
return " ".join(tokens[:2])
|
|
807
831
|
|
|
808
832
|
|
|
833
|
+
def _opportunity_terms(text: str) -> set[str]:
|
|
834
|
+
return {
|
|
835
|
+
token
|
|
836
|
+
for token in re.findall(r"[a-z0-9]+", (text or "").lower())
|
|
837
|
+
if len(token) >= 4 and token not in OPPORTUNITY_DRAIN_STOPWORDS and not token.isdigit()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _opportunity_match(terms: set[str], candidate_text: str, *, min_overlap: int = 4) -> tuple[bool, list[str]]:
|
|
842
|
+
if not terms:
|
|
843
|
+
return False, []
|
|
844
|
+
candidate_terms = _opportunity_terms(candidate_text)
|
|
845
|
+
overlap = sorted(terms & candidate_terms)
|
|
846
|
+
required = min(min_overlap, max(2, len(terms)))
|
|
847
|
+
return len(overlap) >= required, overlap
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _coverage_from_history(conn: sqlite3.Connection, followup_id: str) -> str:
|
|
851
|
+
if not _table_exists(conn, "item_history"):
|
|
852
|
+
return ""
|
|
853
|
+
rows = conn.execute(
|
|
854
|
+
"""SELECT event_type, note
|
|
855
|
+
FROM item_history
|
|
856
|
+
WHERE item_type = 'followup' AND item_id = ?
|
|
857
|
+
ORDER BY created_at DESC, id DESC
|
|
858
|
+
LIMIT 25""",
|
|
859
|
+
(followup_id,),
|
|
860
|
+
).fetchall()
|
|
861
|
+
for row in rows:
|
|
862
|
+
note = str(row["note"] or "").strip()
|
|
863
|
+
if note and OPPORTUNITY_HISTORY_COVERAGE_RE.search(note):
|
|
864
|
+
snippet = " ".join(note.split())[:180]
|
|
865
|
+
return f"history:{row['event_type']}:{snippet}"
|
|
866
|
+
return ""
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _coverage_from_skills(conn: sqlite3.Connection, terms: set[str]) -> str:
|
|
870
|
+
if not _table_exists(conn, "skills"):
|
|
871
|
+
return ""
|
|
872
|
+
rows = conn.execute(
|
|
873
|
+
"""SELECT id, name, description, content, steps, trigger_patterns, tags
|
|
874
|
+
FROM skills
|
|
875
|
+
ORDER BY COALESCE(success_count, 0) DESC, COALESCE(trust_score, 0) DESC
|
|
876
|
+
LIMIT 500"""
|
|
877
|
+
).fetchall()
|
|
878
|
+
for row in rows:
|
|
879
|
+
candidate = " ".join(
|
|
880
|
+
str(row[key] or "")
|
|
881
|
+
for key in ("name", "description", "content", "steps", "trigger_patterns", "tags")
|
|
882
|
+
)
|
|
883
|
+
ok, overlap = _opportunity_match(terms, candidate)
|
|
884
|
+
if ok:
|
|
885
|
+
return f"skill:{row['id']} overlap={','.join(overlap[:6])}"
|
|
886
|
+
return ""
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _coverage_from_scripts(conn: sqlite3.Connection, terms: set[str]) -> str:
|
|
890
|
+
if not _table_exists(conn, "personal_scripts"):
|
|
891
|
+
return ""
|
|
892
|
+
rows = conn.execute(
|
|
893
|
+
"""SELECT id, name, description, path, metadata_json
|
|
894
|
+
FROM personal_scripts
|
|
895
|
+
WHERE COALESCE(enabled, 1) = 1
|
|
896
|
+
LIMIT 500"""
|
|
897
|
+
).fetchall()
|
|
898
|
+
for row in rows:
|
|
899
|
+
candidate = " ".join(
|
|
900
|
+
str(row[key] or "")
|
|
901
|
+
for key in ("name", "description", "path", "metadata_json")
|
|
902
|
+
)
|
|
903
|
+
ok, overlap = _opportunity_match(terms, candidate)
|
|
904
|
+
if ok:
|
|
905
|
+
return f"script:{row['id']} overlap={','.join(overlap[:6])}"
|
|
906
|
+
return ""
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _coverage_from_learnings(conn: sqlite3.Connection, terms: set[str]) -> str:
|
|
910
|
+
if not _table_exists(conn, "learnings"):
|
|
911
|
+
return ""
|
|
912
|
+
rows = conn.execute(
|
|
913
|
+
"""SELECT id, title, content, reasoning, prevention, applies_to
|
|
914
|
+
FROM learnings
|
|
915
|
+
WHERE COALESCE(status, 'active') = 'active'
|
|
916
|
+
ORDER BY COALESCE(updated_at, created_at, 0) DESC
|
|
917
|
+
LIMIT 500"""
|
|
918
|
+
).fetchall()
|
|
919
|
+
for row in rows:
|
|
920
|
+
candidate = " ".join(
|
|
921
|
+
str(row[key] or "")
|
|
922
|
+
for key in ("title", "content", "reasoning", "prevention", "applies_to")
|
|
923
|
+
)
|
|
924
|
+
if not OPPORTUNITY_HISTORY_COVERAGE_RE.search(candidate):
|
|
925
|
+
continue
|
|
926
|
+
ok, overlap = _opportunity_match(terms, candidate, min_overlap=3)
|
|
927
|
+
if ok:
|
|
928
|
+
return f"learning:{row['id']} overlap={','.join(overlap[:6])}"
|
|
929
|
+
return ""
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _covered_opportunity_evidence(conn: sqlite3.Connection, row: sqlite3.Row) -> str:
|
|
933
|
+
followup_id = str(row["id"] or "")
|
|
934
|
+
history = _coverage_from_history(conn, followup_id)
|
|
935
|
+
if history:
|
|
936
|
+
return history
|
|
937
|
+
text = " ".join(
|
|
938
|
+
str(row[key] or "")
|
|
939
|
+
for key in ("description", "verification", "reasoning")
|
|
940
|
+
)
|
|
941
|
+
terms = _opportunity_terms(text)
|
|
942
|
+
return (
|
|
943
|
+
_coverage_from_skills(conn, terms)
|
|
944
|
+
or _coverage_from_scripts(conn, terms)
|
|
945
|
+
or _coverage_from_learnings(conn, terms)
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _drain_covered_opportunity_backlog(conn: sqlite3.Connection, *, limit: int = OPPORTUNITY_DRAIN_LIMIT) -> dict:
|
|
950
|
+
if not _table_exists(conn, "followups"):
|
|
951
|
+
return {"ok": False, "checked": 0, "completed": 0, "reason": "followups_missing"}
|
|
952
|
+
rows = conn.execute(
|
|
953
|
+
"""SELECT id, description, verification, reasoning
|
|
954
|
+
FROM followups
|
|
955
|
+
WHERE id LIKE 'NF-OPPORTUNITY-%'
|
|
956
|
+
AND status NOT LIKE 'COMPLETED%'
|
|
957
|
+
AND UPPER(COALESCE(status, '')) NOT IN ('DELETED','ARCHIVED','BLOCKED','WAITING')
|
|
958
|
+
ORDER BY COALESCE(created_at, updated_at, 0) ASC
|
|
959
|
+
LIMIT ?""",
|
|
960
|
+
(max(1, int(limit or OPPORTUNITY_DRAIN_LIMIT)),),
|
|
961
|
+
).fetchall()
|
|
962
|
+
completed = 0
|
|
963
|
+
examples: list[str] = []
|
|
964
|
+
for row in rows:
|
|
965
|
+
evidence = _covered_opportunity_evidence(conn, row)
|
|
966
|
+
if not evidence:
|
|
967
|
+
continue
|
|
968
|
+
note = (
|
|
969
|
+
"Daily self-audit backlog drain closed this NF-OPPORTUNITY because existing coverage was found. "
|
|
970
|
+
f"Evidence: {evidence}. "
|
|
971
|
+
"No backlog history was deleted; this item remains traceable in item_history."
|
|
972
|
+
)
|
|
973
|
+
conn.commit()
|
|
974
|
+
result = nexo_db.complete_followup(str(row["id"]), note)
|
|
975
|
+
if result.get("error"):
|
|
976
|
+
continue
|
|
977
|
+
completed += 1
|
|
978
|
+
if len(examples) < 5:
|
|
979
|
+
examples.append(f"{row['id']} -> {evidence}")
|
|
980
|
+
return {"ok": True, "checked": len(rows), "completed": completed, "examples": examples}
|
|
981
|
+
|
|
982
|
+
|
|
809
983
|
REPAIR_KEYWORDS = {
|
|
810
984
|
"fix", "fixed", "bug", "bugs", "regression", "regressions", "repair", "repaired",
|
|
811
985
|
"correct", "corrected", "correction", "typo", "hotfix", "patch", "patched",
|
|
@@ -1673,6 +1847,13 @@ def check_automation_opportunities():
|
|
|
1673
1847
|
conn = sqlite3.connect(str(NEXO_DB))
|
|
1674
1848
|
conn.row_factory = sqlite3.Row
|
|
1675
1849
|
if not _table_exists(conn, "protocol_tasks"):
|
|
1850
|
+
drain = _drain_covered_opportunity_backlog(conn)
|
|
1851
|
+
if drain.get("completed"):
|
|
1852
|
+
finding(
|
|
1853
|
+
"INFO",
|
|
1854
|
+
"opportunities",
|
|
1855
|
+
f"drained {drain['completed']} covered NF-OPPORTUNITY backlog item(s)",
|
|
1856
|
+
)
|
|
1676
1857
|
conn.close()
|
|
1677
1858
|
return
|
|
1678
1859
|
|
|
@@ -1684,6 +1865,13 @@ def check_automation_opportunities():
|
|
|
1684
1865
|
ORDER BY closed_at DESC"""
|
|
1685
1866
|
).fetchall()
|
|
1686
1867
|
if not rows:
|
|
1868
|
+
drain = _drain_covered_opportunity_backlog(conn)
|
|
1869
|
+
if drain.get("completed"):
|
|
1870
|
+
finding(
|
|
1871
|
+
"INFO",
|
|
1872
|
+
"opportunities",
|
|
1873
|
+
f"drained {drain['completed']} covered NF-OPPORTUNITY backlog item(s)",
|
|
1874
|
+
)
|
|
1687
1875
|
conn.close()
|
|
1688
1876
|
return
|
|
1689
1877
|
|
|
@@ -1721,6 +1909,17 @@ def check_automation_opportunities():
|
|
|
1721
1909
|
priority="medium",
|
|
1722
1910
|
)
|
|
1723
1911
|
conn.commit()
|
|
1912
|
+
drain = _drain_covered_opportunity_backlog(conn)
|
|
1913
|
+
if drain.get("completed"):
|
|
1914
|
+
example_text = ""
|
|
1915
|
+
examples = drain.get("examples") or []
|
|
1916
|
+
if examples:
|
|
1917
|
+
example_text = f" | examples: {'; '.join(examples)}"
|
|
1918
|
+
finding(
|
|
1919
|
+
"INFO",
|
|
1920
|
+
"opportunities",
|
|
1921
|
+
f"drained {drain['completed']} covered NF-OPPORTUNITY backlog item(s){example_text}",
|
|
1922
|
+
)
|
|
1724
1923
|
conn.close()
|
|
1725
1924
|
|
|
1726
1925
|
|
|
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|
|
34
34
|
import argparse
|
|
35
35
|
import json
|
|
36
36
|
import os
|
|
37
|
+
import re
|
|
37
38
|
import signal
|
|
38
39
|
import subprocess
|
|
39
40
|
import sys
|
|
@@ -85,11 +86,50 @@ CLI_TIMEOUT = 1500
|
|
|
85
86
|
MAX_DUE_ITEMS = 8
|
|
86
87
|
MAX_ACTIVE_ITEMS = 8
|
|
87
88
|
MAX_DIARY_ITEMS = 6
|
|
89
|
+
HISTORY_SIGNAL_LIMIT = 5
|
|
88
90
|
MORNING_BRIEFING_STALE_HOURS = 12
|
|
89
91
|
_ACTIVE_CLAIM: dict[str, str] = {}
|
|
90
92
|
HTTP_TIMEOUT = 7
|
|
91
93
|
NEWS_MAX_HEADLINES = 8
|
|
92
94
|
NEWS_MAX_FEEDS = 5
|
|
95
|
+
RESOLUTION_SIGNAL_WORDS = (
|
|
96
|
+
"already decided",
|
|
97
|
+
"already resolved",
|
|
98
|
+
"cerrado",
|
|
99
|
+
"closed",
|
|
100
|
+
"completed",
|
|
101
|
+
"covered",
|
|
102
|
+
"cubierto",
|
|
103
|
+
"decidido",
|
|
104
|
+
"descartad",
|
|
105
|
+
"false alarm",
|
|
106
|
+
"falsa alarma",
|
|
107
|
+
"monitor activo",
|
|
108
|
+
"no accionable",
|
|
109
|
+
"resolved",
|
|
110
|
+
"resuelto",
|
|
111
|
+
)
|
|
112
|
+
APPROVAL_SIGNAL_WORDS = (
|
|
113
|
+
"awaiting approval",
|
|
114
|
+
"espera luz verde",
|
|
115
|
+
"esperando luz verde",
|
|
116
|
+
"needs approval",
|
|
117
|
+
"pending approval",
|
|
118
|
+
)
|
|
119
|
+
TOPIC_STOPWORDS = {
|
|
120
|
+
"about",
|
|
121
|
+
"after",
|
|
122
|
+
"before",
|
|
123
|
+
"briefing",
|
|
124
|
+
"check",
|
|
125
|
+
"para",
|
|
126
|
+
"pendiente",
|
|
127
|
+
"review",
|
|
128
|
+
"sobre",
|
|
129
|
+
"ticket",
|
|
130
|
+
"update",
|
|
131
|
+
"with",
|
|
132
|
+
}
|
|
93
133
|
NEWS_INTEREST_QUERY_ES = {
|
|
94
134
|
"business": "empresa economia negocios",
|
|
95
135
|
"technology": "tecnologia inteligencia artificial software",
|
|
@@ -454,16 +494,115 @@ def _followup_recency_fields(row: dict) -> dict:
|
|
|
454
494
|
}
|
|
455
495
|
|
|
456
496
|
|
|
497
|
+
def _compact_item_history(item_type: str, item_id: str, *, limit: int = HISTORY_SIGNAL_LIMIT) -> list[dict]:
|
|
498
|
+
if not item_id:
|
|
499
|
+
return []
|
|
500
|
+
try:
|
|
501
|
+
rows = nexo_db.get_item_history(item_type, item_id, limit=limit)
|
|
502
|
+
except Exception:
|
|
503
|
+
return []
|
|
504
|
+
result: list[dict] = []
|
|
505
|
+
for row in rows:
|
|
506
|
+
note = _clean_text(row.get("note"), limit=220)
|
|
507
|
+
event_type = str(row.get("event_type") or "")
|
|
508
|
+
if not note and not event_type:
|
|
509
|
+
continue
|
|
510
|
+
result.append({
|
|
511
|
+
"event_type": event_type,
|
|
512
|
+
"actor": str(row.get("actor") or ""),
|
|
513
|
+
"note": note,
|
|
514
|
+
"created_at": str(row.get("created_at") or ""),
|
|
515
|
+
})
|
|
516
|
+
return result
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _resolution_state(status: str, history: list[dict], *text_fields: object) -> str:
|
|
520
|
+
clean_status = str(status or "").strip().upper()
|
|
521
|
+
if clean_status.startswith("COMPLETED") or clean_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
|
|
522
|
+
return "closed_or_non_operational"
|
|
523
|
+
|
|
524
|
+
signal_text = " ".join(
|
|
525
|
+
[
|
|
526
|
+
str(status or ""),
|
|
527
|
+
*[str(value or "") for value in text_fields],
|
|
528
|
+
*[
|
|
529
|
+
f"{entry.get('event_type', '')} {entry.get('note', '')}"
|
|
530
|
+
for entry in history
|
|
531
|
+
if isinstance(entry, dict)
|
|
532
|
+
],
|
|
533
|
+
]
|
|
534
|
+
).lower()
|
|
535
|
+
if any(word in signal_text for word in APPROVAL_SIGNAL_WORDS):
|
|
536
|
+
return "awaiting_user_approval"
|
|
537
|
+
if any(word in signal_text for word in RESOLUTION_SIGNAL_WORDS):
|
|
538
|
+
return "resolved_or_decided_signal"
|
|
539
|
+
return "active"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _status_claim_guard(resolution_state: str) -> str:
|
|
543
|
+
if resolution_state == "awaiting_user_approval":
|
|
544
|
+
return "Do not describe as authorized/done; state that it is waiting for approval."
|
|
545
|
+
if resolution_state == "resolved_or_decided_signal":
|
|
546
|
+
return "Do not present resolved/decided subtopics as new decisions."
|
|
547
|
+
if resolution_state == "closed_or_non_operational":
|
|
548
|
+
return "Do not present this as operationally pending."
|
|
549
|
+
return ""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _topic_signature(item: dict) -> str:
|
|
553
|
+
text = " ".join(
|
|
554
|
+
str(item.get(key) or "")
|
|
555
|
+
for key in ("description", "verification", "reasoning")
|
|
556
|
+
)
|
|
557
|
+
normalized = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower()
|
|
558
|
+
tokens = [
|
|
559
|
+
token
|
|
560
|
+
for token in re.findall(r"[a-z0-9]{4,}", normalized)
|
|
561
|
+
if token not in TOPIC_STOPWORDS
|
|
562
|
+
]
|
|
563
|
+
unique = list(dict.fromkeys(tokens))
|
|
564
|
+
if len(unique) < 3:
|
|
565
|
+
return f"id:{item.get('id', '')}"
|
|
566
|
+
return "topic:" + " ".join(sorted(unique[:10]))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _dedupe_context_groups(*groups: list[dict]) -> tuple[list[dict], ...]:
|
|
570
|
+
seen: set[str] = set()
|
|
571
|
+
output: list[list[dict]] = []
|
|
572
|
+
for group in groups:
|
|
573
|
+
kept: list[dict] = []
|
|
574
|
+
for item in group:
|
|
575
|
+
signature = _topic_signature(item)
|
|
576
|
+
if signature and signature in seen:
|
|
577
|
+
continue
|
|
578
|
+
if signature:
|
|
579
|
+
seen.add(signature)
|
|
580
|
+
kept.append(item)
|
|
581
|
+
output.append(kept)
|
|
582
|
+
return tuple(output)
|
|
583
|
+
|
|
584
|
+
|
|
457
585
|
def _serialize_reminders(filter_type: str, *, limit: int) -> list[dict]:
|
|
458
586
|
rows = list(nexo_db.get_reminders(filter_type))
|
|
459
587
|
result: list[dict] = []
|
|
460
588
|
for row in rows[:limit]:
|
|
589
|
+
item_id = str(row.get("id") or "")
|
|
590
|
+
history = _compact_item_history("reminder", item_id)
|
|
591
|
+
resolution_state = _resolution_state(
|
|
592
|
+
str(row.get("status") or ""),
|
|
593
|
+
history,
|
|
594
|
+
row.get("description"),
|
|
595
|
+
)
|
|
461
596
|
result.append({
|
|
462
|
-
"id":
|
|
597
|
+
"id": item_id,
|
|
463
598
|
"description": _clean_text(row.get("description")),
|
|
464
599
|
"date": str(row.get("date") or ""),
|
|
465
600
|
"category": str(row.get("category") or ""),
|
|
466
601
|
"status": str(row.get("status") or ""),
|
|
602
|
+
"recent_history": history,
|
|
603
|
+
"resolution_state": resolution_state,
|
|
604
|
+
"has_resolution_signal": resolution_state in {"closed_or_non_operational", "resolved_or_decided_signal"},
|
|
605
|
+
"status_claim_guard": _status_claim_guard(resolution_state),
|
|
467
606
|
})
|
|
468
607
|
return result
|
|
469
608
|
|
|
@@ -475,8 +614,17 @@ def _serialize_followups(filter_type: str, *, limit: int) -> list[dict]:
|
|
|
475
614
|
status = str(row.get("status") or "").strip().upper()
|
|
476
615
|
if status.startswith("COMPLETED") or status in {"DELETED", "ARCHIVED"}:
|
|
477
616
|
continue
|
|
617
|
+
item_id = str(row.get("id") or "")
|
|
618
|
+
history = _compact_item_history("followup", item_id)
|
|
619
|
+
resolution_state = _resolution_state(
|
|
620
|
+
row.get("status"),
|
|
621
|
+
history,
|
|
622
|
+
row.get("description"),
|
|
623
|
+
row.get("verification"),
|
|
624
|
+
row.get("reasoning"),
|
|
625
|
+
)
|
|
478
626
|
item = {
|
|
479
|
-
"id":
|
|
627
|
+
"id": item_id,
|
|
480
628
|
"description": _clean_text(row.get("description")),
|
|
481
629
|
"date": str(row.get("date") or ""),
|
|
482
630
|
"priority": _item_priority(row.get("priority")),
|
|
@@ -484,6 +632,10 @@ def _serialize_followups(filter_type: str, *, limit: int) -> list[dict]:
|
|
|
484
632
|
"status": str(row.get("status") or ""),
|
|
485
633
|
"verification": _clean_text(row.get("verification"), limit=180),
|
|
486
634
|
"reasoning": _clean_text(row.get("reasoning"), limit=180),
|
|
635
|
+
"recent_history": history,
|
|
636
|
+
"resolution_state": resolution_state,
|
|
637
|
+
"has_resolution_signal": resolution_state in {"closed_or_non_operational", "resolved_or_decided_signal"},
|
|
638
|
+
"status_claim_guard": _status_claim_guard(resolution_state),
|
|
487
639
|
}
|
|
488
640
|
item.update(_followup_recency_fields(row))
|
|
489
641
|
result.append(item)
|
|
@@ -935,6 +1087,12 @@ def collect_context(profile: dict, preferences: dict | None = None) -> dict:
|
|
|
935
1087
|
for row in _serialize_reminders("active", limit=MAX_ACTIVE_ITEMS + MAX_DUE_ITEMS)
|
|
936
1088
|
if row["id"] not in due_reminder_ids
|
|
937
1089
|
][:MAX_ACTIVE_ITEMS]
|
|
1090
|
+
due_followups, active_followups, due_reminders, active_reminders = _dedupe_context_groups(
|
|
1091
|
+
due_followups,
|
|
1092
|
+
active_followups,
|
|
1093
|
+
due_reminders,
|
|
1094
|
+
active_reminders,
|
|
1095
|
+
)
|
|
938
1096
|
recent_sent = _serialize_recent_sent_emails()
|
|
939
1097
|
external = _collect_external_context(profile, preferences)
|
|
940
1098
|
return {
|
package/src/server.py
CHANGED
|
@@ -132,9 +132,12 @@ from tools_guardian import handle_guardian_rule_override
|
|
|
132
132
|
from tools_api_call import (
|
|
133
133
|
handle_api_call,
|
|
134
134
|
handle_create_app_token,
|
|
135
|
+
handle_support_ticket_close,
|
|
135
136
|
handle_support_ticket_create,
|
|
136
137
|
handle_support_ticket_list,
|
|
138
|
+
handle_support_ticket_message,
|
|
137
139
|
handle_support_ticket_read,
|
|
140
|
+
handle_support_ticket_reopen,
|
|
138
141
|
)
|
|
139
142
|
from runtime_versioning import (
|
|
140
143
|
RestartRequiredMiddleware,
|
|
@@ -3198,9 +3201,33 @@ def nexo_support_ticket_read(ticket_id: str) -> str:
|
|
|
3198
3201
|
|
|
3199
3202
|
|
|
3200
3203
|
@mcp.tool
|
|
3201
|
-
def nexo_support_ticket_create(
|
|
3204
|
+
def nexo_support_ticket_create(
|
|
3205
|
+
subject: str,
|
|
3206
|
+
message: str,
|
|
3207
|
+
priority: str = "normal",
|
|
3208
|
+
client_message_id: str = "",
|
|
3209
|
+
origin: str = "desktop",
|
|
3210
|
+
) -> str:
|
|
3202
3211
|
"""Create a real NEXO support ticket for a product bug/setup issue."""
|
|
3203
|
-
return handle_support_ticket_create(subject, message, priority)
|
|
3212
|
+
return handle_support_ticket_create(subject, message, priority, client_message_id, origin)
|
|
3213
|
+
|
|
3214
|
+
|
|
3215
|
+
@mcp.tool
|
|
3216
|
+
def nexo_support_ticket_message(ticket_id: str, body: str, client_message_id: str = "") -> str:
|
|
3217
|
+
"""Append an evidence note to a real NEXO support ticket before status changes."""
|
|
3218
|
+
return handle_support_ticket_message(ticket_id, body, client_message_id)
|
|
3219
|
+
|
|
3220
|
+
|
|
3221
|
+
@mcp.tool
|
|
3222
|
+
def nexo_support_ticket_close(ticket_id: str) -> str:
|
|
3223
|
+
"""Close a real NEXO support ticket after evidence has been recorded."""
|
|
3224
|
+
return handle_support_ticket_close(ticket_id)
|
|
3225
|
+
|
|
3226
|
+
|
|
3227
|
+
@mcp.tool
|
|
3228
|
+
def nexo_support_ticket_reopen(ticket_id: str) -> str:
|
|
3229
|
+
"""Reopen a real NEXO support ticket if fresh evidence shows it is active."""
|
|
3230
|
+
return handle_support_ticket_reopen(ticket_id)
|
|
3204
3231
|
|
|
3205
3232
|
|
|
3206
3233
|
@mcp.tool
|
package/src/tools_api_call.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"""HTTP API calls to the nexo-desktop-web backend using the user's session bearer.
|
|
2
2
|
|
|
3
|
-
Exposes
|
|
3
|
+
Exposes 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
6
|
- nexo_support_ticket_list(status, limit)
|
|
7
7
|
- nexo_support_ticket_read(ticket_id)
|
|
8
8
|
- nexo_support_ticket_create(subject, message, priority)
|
|
9
|
+
- nexo_support_ticket_message(ticket_id, body, client_message_id)
|
|
10
|
+
- nexo_support_ticket_close(ticket_id)
|
|
11
|
+
- nexo_support_ticket_reopen(ticket_id)
|
|
9
12
|
|
|
10
13
|
The session bearer (Sanctum personal access token) is stored by NEXO Desktop in
|
|
11
14
|
the OS keychain at:
|
|
@@ -210,7 +213,7 @@ def handle_support_ticket_list(status: str = "", limit: int = 20) -> str:
|
|
|
210
213
|
parsed_limit = max(1, min(100, int(limit or 20)))
|
|
211
214
|
except Exception:
|
|
212
215
|
parsed_limit = 20
|
|
213
|
-
query["
|
|
216
|
+
query["per_page"] = str(parsed_limit)
|
|
214
217
|
suffix = "?" + urlencode(query) if query else ""
|
|
215
218
|
return handle_api_call("GET", f"/api/support/tickets{suffix}")
|
|
216
219
|
|
|
@@ -223,20 +226,75 @@ def handle_support_ticket_read(ticket_id: str) -> str:
|
|
|
223
226
|
return handle_api_call("GET", f"/api/support/tickets/{quote(clean, safe='')}")
|
|
224
227
|
|
|
225
228
|
|
|
226
|
-
def
|
|
229
|
+
def _normalize_support_priority(priority: str) -> str:
|
|
230
|
+
clean_priority = (priority or "normal").strip().lower()
|
|
231
|
+
if clean_priority == "urgent":
|
|
232
|
+
return "critical"
|
|
233
|
+
if clean_priority not in {"low", "normal", "high", "critical"}:
|
|
234
|
+
return "normal"
|
|
235
|
+
return clean_priority
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def handle_support_ticket_create(
|
|
239
|
+
subject: str,
|
|
240
|
+
message: str,
|
|
241
|
+
priority: str = "normal",
|
|
242
|
+
client_message_id: str = "",
|
|
243
|
+
origin: str = "desktop",
|
|
244
|
+
) -> str:
|
|
227
245
|
"""Create a real NEXO support ticket instead of a private/internal followup."""
|
|
228
246
|
clean_subject = (subject or "").strip()
|
|
229
247
|
clean_message = (message or "").strip()
|
|
230
|
-
clean_priority = (priority
|
|
248
|
+
clean_priority = _normalize_support_priority(priority)
|
|
249
|
+
clean_origin = (origin or "desktop").strip().lower()
|
|
231
250
|
if not clean_subject:
|
|
232
251
|
return "ERROR: subject is required."
|
|
233
252
|
if not clean_message:
|
|
234
253
|
return "ERROR: message is required."
|
|
235
|
-
if
|
|
236
|
-
|
|
254
|
+
if clean_origin not in {"desktop", "web", "auto_incident"}:
|
|
255
|
+
clean_origin = "desktop"
|
|
237
256
|
payload = {
|
|
238
|
-
"
|
|
239
|
-
"
|
|
257
|
+
"title": clean_subject,
|
|
258
|
+
"description": clean_message,
|
|
240
259
|
"priority": clean_priority,
|
|
260
|
+
"origin": clean_origin,
|
|
241
261
|
}
|
|
262
|
+
clean_client_message_id = (client_message_id or "").strip()
|
|
263
|
+
if clean_client_message_id:
|
|
264
|
+
payload["client_message_id"] = clean_client_message_id
|
|
242
265
|
return handle_api_call("POST", "/api/support/tickets", body_json=json.dumps(payload, ensure_ascii=False))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def handle_support_ticket_message(ticket_id: str, body: str, client_message_id: str = "") -> str:
|
|
269
|
+
"""Append an evidence note to a real NEXO support ticket."""
|
|
270
|
+
clean = (ticket_id or "").strip()
|
|
271
|
+
clean_body = (body or "").strip()
|
|
272
|
+
if not clean:
|
|
273
|
+
return "ERROR: ticket_id is required."
|
|
274
|
+
if not clean_body:
|
|
275
|
+
return "ERROR: body is required."
|
|
276
|
+
payload = {"body": clean_body}
|
|
277
|
+
clean_client_message_id = (client_message_id or "").strip()
|
|
278
|
+
if clean_client_message_id:
|
|
279
|
+
payload["client_message_id"] = clean_client_message_id
|
|
280
|
+
return handle_api_call(
|
|
281
|
+
"POST",
|
|
282
|
+
f"/api/support/tickets/{quote(clean, safe='')}/messages",
|
|
283
|
+
body_json=json.dumps(payload, ensure_ascii=False),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def handle_support_ticket_close(ticket_id: str) -> str:
|
|
288
|
+
"""Close a real NEXO support ticket after evidence has been recorded."""
|
|
289
|
+
clean = (ticket_id or "").strip()
|
|
290
|
+
if not clean:
|
|
291
|
+
return "ERROR: ticket_id is required."
|
|
292
|
+
return handle_api_call("POST", f"/api/support/tickets/{quote(clean, safe='')}/close")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def handle_support_ticket_reopen(ticket_id: str) -> str:
|
|
296
|
+
"""Reopen a real NEXO support ticket when fresh evidence shows it is still active."""
|
|
297
|
+
clean = (ticket_id or "").strip()
|
|
298
|
+
if not clean:
|
|
299
|
+
return "ERROR: ticket_id is required."
|
|
300
|
+
return handle_api_call("POST", f"/api/support/tickets/{quote(clean, safe='')}/reopen")
|
|
@@ -20,6 +20,10 @@ Hard rules:
|
|
|
20
20
|
- Prioritise what changed recently, what is due now, what is blocked, and what deserves focus today.
|
|
21
21
|
- If activity was quiet, say so plainly instead of padding.
|
|
22
22
|
- Mention operator decisions only when the context actually supports them.
|
|
23
|
+
- Treat `recent_history`, `resolution_state`, `has_resolution_signal`, and `status_claim_guard` as stronger evidence than an older description field. If history says a subtopic was decided, resolved, discarded, covered, or moved to monitoring, do not ask the operator to decide it again.
|
|
24
|
+
- If a followup remains pending for one reason but its description mentions another subtopic already decided in history, discuss only the still-open reason. Do not drag the decided subtopic back into "waiting for your decision".
|
|
25
|
+
- Do not duplicate the same topic across sections. If one item could fit Top priorities and Decisions/green lights, mention it once in the most useful section.
|
|
26
|
+
- Never say "authorized", "done", "deployed", "closed", or equivalent unless the structured context provides direct evidence in status, verification, recent_history, sent email, or external verified data. If evidence is missing or contradictory, say the exact current state such as "waiting for approval", "in diagnosis", or "not verified yet".
|
|
23
27
|
- Treat followup recency as evidence: `last_activity`, `days_open`, `days_since_activity`, and `stale_without_recent_signal` are there to prevent stale items from becoming today's top action by accident.
|
|
24
28
|
- Do not promote a followup to opening/top priority/decision of the day when it is `owner=user`, stale for 3+ days, or its own description/diaries say the incident is contained, stable, historical, already resolved, or waiting only for a billing/admin confirmation. In that case mention it, if useful, as "risk in seguimiento" or "pendiente administrativo", not as a live crisis.
|
|
25
29
|
- Never reconstruct an old crisis from a contained followup. If the context says a service is stable after a date/time, use that stability as the current status unless there is fresh contrary evidence in the structured context.
|
|
@@ -354,6 +354,19 @@
|
|
|
354
354
|
},
|
|
355
355
|
"triggers_after": []
|
|
356
356
|
},
|
|
357
|
+
"nexo_support_ticket_close": {
|
|
358
|
+
"description": "Close a real authenticated NEXO support ticket",
|
|
359
|
+
"category": "support",
|
|
360
|
+
"source": "server",
|
|
361
|
+
"requires": [],
|
|
362
|
+
"provides": ["support_ticket"],
|
|
363
|
+
"internal_calls": ["nexo_api_call"],
|
|
364
|
+
"enforcement": {
|
|
365
|
+
"level": "none",
|
|
366
|
+
"rules": []
|
|
367
|
+
},
|
|
368
|
+
"triggers_after": []
|
|
369
|
+
},
|
|
357
370
|
"nexo_support_ticket_list": {
|
|
358
371
|
"description": "List authenticated NEXO support tickets",
|
|
359
372
|
"category": "support",
|
|
@@ -367,6 +380,19 @@
|
|
|
367
380
|
},
|
|
368
381
|
"triggers_after": []
|
|
369
382
|
},
|
|
383
|
+
"nexo_support_ticket_message": {
|
|
384
|
+
"description": "Append an evidence note to a real authenticated NEXO support ticket",
|
|
385
|
+
"category": "support",
|
|
386
|
+
"source": "server",
|
|
387
|
+
"requires": [],
|
|
388
|
+
"provides": ["support_ticket"],
|
|
389
|
+
"internal_calls": ["nexo_api_call"],
|
|
390
|
+
"enforcement": {
|
|
391
|
+
"level": "none",
|
|
392
|
+
"rules": []
|
|
393
|
+
},
|
|
394
|
+
"triggers_after": []
|
|
395
|
+
},
|
|
370
396
|
"nexo_support_ticket_read": {
|
|
371
397
|
"description": "Read one authenticated NEXO support ticket by ID",
|
|
372
398
|
"category": "support",
|
|
@@ -380,6 +406,19 @@
|
|
|
380
406
|
},
|
|
381
407
|
"triggers_after": []
|
|
382
408
|
},
|
|
409
|
+
"nexo_support_ticket_reopen": {
|
|
410
|
+
"description": "Reopen a real authenticated NEXO support ticket",
|
|
411
|
+
"category": "support",
|
|
412
|
+
"source": "server",
|
|
413
|
+
"requires": [],
|
|
414
|
+
"provides": ["support_ticket"],
|
|
415
|
+
"internal_calls": ["nexo_api_call"],
|
|
416
|
+
"enforcement": {
|
|
417
|
+
"level": "none",
|
|
418
|
+
"rules": []
|
|
419
|
+
},
|
|
420
|
+
"triggers_after": []
|
|
421
|
+
},
|
|
383
422
|
"nexo_artifact_create": {
|
|
384
423
|
"description": "Register artifact (service, dashboard, script)",
|
|
385
424
|
"category": "artifact",
|