nexo-brain 7.37.1 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.37.1",
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,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.37.1` is the current packaged-runtime line. Patch release over v7.37.0 - release hardening for Desktop-bundled Brain: large existing `local-context.db` files no longer run a surprise full `VACUUM` on the first writer, `schema_abstraction` MCP tools are loaded by the essential startup set, and learning tools tolerate Desktop compatibility payloads. Builds on v7.37.0 (transparent server self-heal + email zombie reinjection guard).
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.
24
+
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.
26
+
27
+ Previously in `7.37.1`: patch release over v7.37.0 - release hardening for Desktop-bundled Brain: large existing `local-context.db` files no longer run a surprise full `VACUUM` on the first writer, `schema_abstraction` MCP tools are loaded by the essential startup set, and learning tools tolerate Desktop compatibility payloads. Builds on v7.37.0 (transparent server self-heal + email zombie reinjection guard).
22
28
 
23
29
  Previously in `7.31.9`: patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
24
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.37.1",
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",
package/src/db/_core.py CHANGED
@@ -131,7 +131,8 @@ class _SerializedConnection:
131
131
  return self._conn.commit()
132
132
 
133
133
  def close(self):
134
- return self._conn.close()
134
+ with _write_lock:
135
+ return self._conn.close()
135
136
 
136
137
  def __getattr__(self, name):
137
138
  return getattr(self._conn, name)
@@ -6,9 +6,9 @@
6
6
  "chrome-devtools-mcp": {
7
7
  "source_type": "npm",
8
8
  "package": "chrome-devtools-mcp",
9
- "version": "1.2.0",
10
- "integrity": "sha512-xHd8hoLZQArDsYhu8OUHvKBIiihx1Co9DgAPHWaM4kzRf41TpZ0IuxKioIWTEGzFKpRqQzIxpFqydY4AKqP5sQ==",
11
- "tarball": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-1.2.0.tgz",
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"
@@ -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
- if _row_ref_matches(request.query, row):
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": round(max(float(proposed_fix.get("confidence", 0.0) or 0.0), 0.86 if severity == "high" else 0.78), 2),
148
- "impact": "high" if severity == "high" else "medium",
194
+ "confidence": confidence,
195
+ "impact": impact,
149
196
  "reversibility": "reversible",
150
- "evidence": pattern.get("evidence", []) or [],
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": str(row.get("id") or ""),
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": str(row.get("id") or ""),
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(subject: str, message: str, priority: str = "normal") -> str:
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
@@ -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 two MCP tools registered in server.py:
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["limit"] = str(parsed_limit)
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 handle_support_ticket_create(subject: str, message: str, priority: str = "normal") -> str:
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 or "normal").strip().lower()
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 clean_priority not in {"low", "normal", "high", "urgent"}:
236
- clean_priority = "normal"
254
+ if clean_origin not in {"desktop", "web", "auto_incident"}:
255
+ clean_origin = "desktop"
237
256
  payload = {
238
- "subject": clean_subject,
239
- "message": clean_message,
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")
@@ -111,7 +111,7 @@ def _safe_packet_payload(value, *, _depth: int = 0):
111
111
  return [_safe_packet_payload(item, _depth=_depth + 1) for item in list(value)[:100]]
112
112
  return _safe_packet_text(value)
113
113
 
114
- _keepalive_threads: dict[str, threading.Event] = {} # sid stop_event
114
+ _keepalive_threads: dict[str, tuple[threading.Event, threading.Thread]] = {} # sid -> (stop_event, thread)
115
115
 
116
116
 
117
117
  def _env_flag(name: str, default: bool = False) -> bool:
@@ -235,16 +235,34 @@ def _start_keepalive(sid: str) -> None:
235
235
  """Start a keepalive thread for the given session."""
236
236
  _stop_keepalive(sid) # clean up any leftover
237
237
  stop_event = threading.Event()
238
- _keepalive_threads[sid] = stop_event
239
238
  t = threading.Thread(target=_keepalive_loop, args=(sid, stop_event), daemon=True)
239
+ _keepalive_threads[sid] = (stop_event, t)
240
240
  t.start()
241
241
 
242
242
 
243
- def _stop_keepalive(sid: str) -> None:
243
+ def _stop_keepalive(sid: str, join_timeout: float = 1.0) -> None:
244
244
  """Signal the keepalive thread for the given session to stop."""
245
- stop_event = _keepalive_threads.pop(sid, None)
246
- if stop_event is not None:
245
+ entry = _keepalive_threads.pop(sid, None)
246
+ if entry is None:
247
+ return
248
+ stop_event, thread = entry
249
+ stop_event.set()
250
+ if thread is not threading.current_thread():
251
+ thread.join(timeout=max(0.0, join_timeout))
252
+
253
+
254
+ def _stop_all_keepalives(join_timeout: float = 1.0) -> None:
255
+ """Signal and briefly join all keepalive threads before DB shutdown."""
256
+ entries = list(_keepalive_threads.values())
257
+ _keepalive_threads.clear()
258
+ for stop_event, _thread in entries:
247
259
  stop_event.set()
260
+ deadline = time.monotonic() + max(0.0, join_timeout)
261
+ for _stop_event, thread in entries:
262
+ if thread is threading.current_thread():
263
+ continue
264
+ remaining = max(0.0, deadline - time.monotonic())
265
+ thread.join(timeout=remaining)
248
266
 
249
267
 
250
268
  def _generate_sid() -> str:
@@ -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",
@@ -3283,6 +3322,19 @@
3283
3322
  },
3284
3323
  "triggers_after": []
3285
3324
  },
3325
+ "nexo_memory_forget": {
3326
+ "description": "Selective forget for revoked secrets or reversible fact correction",
3327
+ "category": "memory",
3328
+ "source": "server",
3329
+ "requires": [],
3330
+ "provides": [],
3331
+ "internal_calls": [],
3332
+ "enforcement": {
3333
+ "level": "none",
3334
+ "rules": []
3335
+ },
3336
+ "triggers_after": []
3337
+ },
3286
3338
  "nexo_memory_health": {
3287
3339
  "description": "Report Memory Observations v2 health and table status",
3288
3340
  "category": "memory",