nexo-brain 5.3.26 → 5.3.27

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.
Files changed (211) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/server.py +3 -0
  4. package/src/tools_sessions.py +6 -1
  5. package/src/dashboard/static/favicon 2.svg +0 -32
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +0 -40
  8. package/src/dashboard/static/style 2.css +0 -2458
  9. package/src/dashboard/templates/adaptive 2.html +0 -118
  10. package/src/dashboard/templates/artifacts 2.html +0 -133
  11. package/src/dashboard/templates/backups 2.html +0 -136
  12. package/src/dashboard/templates/base 2.html +0 -417
  13. package/src/dashboard/templates/calendar 2.html +0 -591
  14. package/src/dashboard/templates/chat 2.html +0 -356
  15. package/src/dashboard/templates/claims 2.html +0 -259
  16. package/src/dashboard/templates/cortex 2.html +0 -321
  17. package/src/dashboard/templates/credentials 2.html +0 -128
  18. package/src/dashboard/templates/crons 2.html +0 -370
  19. package/src/dashboard/templates/dashboard 2.html +0 -494
  20. package/src/dashboard/templates/dreams 2.html +0 -252
  21. package/src/dashboard/templates/email 2.html +0 -160
  22. package/src/dashboard/templates/evolution 2.html +0 -189
  23. package/src/dashboard/templates/feed 2.html +0 -249
  24. package/src/dashboard/templates/followup_health 2.html +0 -170
  25. package/src/dashboard/templates/graph 2.html +0 -201
  26. package/src/dashboard/templates/guard 2.html +0 -259
  27. package/src/dashboard/templates/inbox 2.html +0 -251
  28. package/src/dashboard/templates/memory 2.html +0 -420
  29. package/src/dashboard/templates/operations 2.html +0 -608
  30. package/src/dashboard/templates/plugins 2.html +0 -185
  31. package/src/dashboard/templates/protocol 2.html +0 -199
  32. package/src/dashboard/templates/rules 2.html +0 -246
  33. package/src/dashboard/templates/sentiment 2.html +0 -247
  34. package/src/dashboard/templates/sessions 2.html +0 -218
  35. package/src/dashboard/templates/skills 2.html +0 -329
  36. package/src/dashboard/templates/somatic 2.html +0 -73
  37. package/src/dashboard/templates/triggers 2.html +0 -133
  38. package/src/dashboard/templates/trust 2.html +0 -360
  39. package/src/db/__init__ 2.py +0 -259
  40. package/src/db/_core 2.py +0 -437
  41. package/src/db/_credentials 2.py +0 -124
  42. package/src/db/_episodic 2.py +0 -762
  43. package/src/db/_evolution 2.py +0 -54
  44. package/src/db/_fts 2.py +0 -406
  45. package/src/db/_goal_profiles 2.py +0 -376
  46. package/src/db/_hot_context 2.py +0 -660
  47. package/src/db/_outcomes 2.py +0 -800
  48. package/src/db/_personal_scripts 2.py +0 -582
  49. package/src/db/_sessions 2.py +0 -330
  50. package/src/db/_tasks 2.py +0 -91
  51. package/src/db/_watchers 2.py +0 -173
  52. package/src/doctor/formatters 2.py +0 -52
  53. package/src/doctor/models 2.py +0 -69
  54. package/src/doctor/planes 2.py +0 -87
  55. package/src/doctor/providers/__init__ 2.py +0 -1
  56. package/src/doctor/providers/deep 2.py +0 -367
  57. package/src/evolution_cycle 2.py +0 -519
  58. package/src/hooks/auto_capture 2.py +0 -208
  59. package/src/hooks/caffeinate-guard 2.sh +0 -8
  60. package/src/hooks/capture-session 2.sh +0 -21
  61. package/src/hooks/capture-tool-logs 2.sh +0 -158
  62. package/src/hooks/daily-briefing-check 2.sh +0 -33
  63. package/src/hooks/heartbeat-enforcement 2.py +0 -90
  64. package/src/hooks/heartbeat-posttool 2.sh +0 -18
  65. package/src/hooks/inbox-hook 2.sh +0 -76
  66. package/src/hooks/post-compact 2.sh +0 -152
  67. package/src/hooks/pre-compact 2.sh +0 -169
  68. package/src/hooks/protocol-guardrail 2.sh +0 -10
  69. package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
  70. package/src/hooks/session-stop 2.sh +0 -52
  71. package/src/kg_populate 2.py +0 -292
  72. package/src/maintenance 2.py +0 -53
  73. package/src/memory_backends 2.py +0 -71
  74. package/src/migrate_embeddings 2.py +0 -124
  75. package/src/nexo_sdk 2.py +0 -103
  76. package/src/observability 2.py +0 -199
  77. package/src/plugin_loader 2.py +0 -217
  78. package/src/plugins/__init__ 2.py +0 -0
  79. package/src/plugins/artifact_registry 2.py +0 -450
  80. package/src/plugins/backup 2.py +0 -127
  81. package/src/plugins/claims_tools 2.py +0 -119
  82. package/src/plugins/cognitive_memory 2.py +0 -609
  83. package/src/plugins/core_rules 2.py +0 -252
  84. package/src/plugins/cortex 2.py +0 -1155
  85. package/src/plugins/entities 2.py +0 -67
  86. package/src/plugins/episodic_memory 2.py +0 -560
  87. package/src/plugins/evolution 2.py +0 -167
  88. package/src/plugins/goal_engine 2.py +0 -142
  89. package/src/plugins/guard 2.py +0 -862
  90. package/src/plugins/impact 2.py +0 -29
  91. package/src/plugins/knowledge_graph_tools 2.py +0 -137
  92. package/src/plugins/media_memory_tools 2.py +0 -98
  93. package/src/plugins/memory_export 2.py +0 -196
  94. package/src/plugins/outcomes 2.py +0 -130
  95. package/src/plugins/personal_scripts 2.py +0 -117
  96. package/src/plugins/preferences 2.py +0 -47
  97. package/src/plugins/protocol 2.py +0 -1449
  98. package/src/plugins/simple_api 2.py +0 -106
  99. package/src/plugins/skills 2.py +0 -341
  100. package/src/plugins/state_watchers 2.py +0 -79
  101. package/src/plugins/update 2.py +0 -986
  102. package/src/plugins/user_state_tools 2.py +0 -43
  103. package/src/plugins/workflow 2.py +0 -588
  104. package/src/protocol_settings 2.py +0 -59
  105. package/src/public_contribution 2.py +0 -466
  106. package/src/public_evolution_queue 2.py +0 -241
  107. package/src/requirements 2.txt +0 -14
  108. package/src/retroactive_learnings 2.py +0 -373
  109. package/src/rules/__init__ 2.py +0 -0
  110. package/src/rules/core-rules 2.json +0 -331
  111. package/src/rules/migrate 2.py +0 -207
  112. package/src/runtime_power 2.py +0 -874
  113. package/src/script_registry 2.py +0 -1559
  114. package/src/scripts/check-context 2.py +0 -272
  115. package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
  116. package/src/scripts/deep-sleep/collect 2.py +0 -928
  117. package/src/scripts/deep-sleep/extract 2.py +0 -330
  118. package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
  119. package/src/scripts/deep-sleep/synthesize 2.py +0 -312
  120. package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
  121. package/src/scripts/nexo-agent-run 2.py +0 -75
  122. package/src/scripts/nexo-auto-update 2.py +0 -6
  123. package/src/scripts/nexo-backup 2.sh +0 -25
  124. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  125. package/src/scripts/nexo-catchup 2.py +0 -300
  126. package/src/scripts/nexo-cognitive-decay 2.py +0 -257
  127. package/src/scripts/nexo-cortex-cycle 2.py +0 -293
  128. package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
  129. package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
  130. package/src/scripts/nexo-dashboard 2.sh +0 -29
  131. package/src/scripts/nexo-deep-sleep 2.sh +0 -86
  132. package/src/scripts/nexo-evolution-run 2.py +0 -1664
  133. package/src/scripts/nexo-followup-hygiene 2.py +0 -139
  134. package/src/scripts/nexo-hook-record 2.py +0 -42
  135. package/src/scripts/nexo-immune 2.py +0 -936
  136. package/src/scripts/nexo-impact-scorer 2.py +0 -117
  137. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  138. package/src/scripts/nexo-install 2.py +0 -6
  139. package/src/scripts/nexo-learning-housekeep 2.py +0 -401
  140. package/src/scripts/nexo-learning-validator 2.py +0 -266
  141. package/src/scripts/nexo-migrate 2.py +0 -260
  142. package/src/scripts/nexo-outcome-checker 2.py +0 -127
  143. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
  144. package/src/scripts/nexo-pre-commit 2.py +0 -120
  145. package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
  146. package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
  147. package/src/scripts/nexo-reflection 2.py +0 -256
  148. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  149. package/src/scripts/nexo-sleep 2.py +0 -631
  150. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  151. package/src/scripts/nexo-sync-clients 2.py +0 -16
  152. package/src/scripts/nexo-synthesis 2.py +0 -475
  153. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  154. package/src/scripts/nexo-update 2.sh +0 -306
  155. package/src/scripts/nexo-watchdog 2.sh +0 -1207
  156. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  157. package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
  158. package/src/server 2.py +0 -1296
  159. package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
  160. package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
  161. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
  162. package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
  163. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
  164. package/src/skills/run-release-final-audit/guide 2.md +0 -16
  165. package/src/skills/run-release-final-audit/script 2.py +0 -259
  166. package/src/skills/run-release-final-audit/skill 2.json +0 -77
  167. package/src/skills/run-runtime-doctor/guide 2.md +0 -12
  168. package/src/skills/run-runtime-doctor/script 2.py +0 -21
  169. package/src/skills/run-runtime-doctor/skill 2.json +0 -25
  170. package/src/skills_runtime 2.py +0 -932
  171. package/src/state_watchers_runtime 2.py +0 -475
  172. package/src/storage_router 2.py +0 -32
  173. package/src/system_catalog 2.py +0 -786
  174. package/src/tools_coordination 2.py +0 -103
  175. package/src/tools_credentials 2.py +0 -68
  176. package/src/tools_drive 2.py +0 -487
  177. package/src/tools_hot_context 2.py +0 -163
  178. package/src/tools_learnings 2.py +0 -612
  179. package/src/tools_menu 2.py +0 -229
  180. package/src/tools_reminders 2.py +0 -88
  181. package/src/tools_reminders_crud 2.py +0 -363
  182. package/src/tools_sessions 2.py +0 -1054
  183. package/src/tools_system_catalog 2.py +0 -19
  184. package/src/tools_task_history 2.py +0 -57
  185. package/src/tools_transcripts 2.py +0 -98
  186. package/src/transcript_utils 2.py +0 -412
  187. package/src/user_context 2.py +0 -46
  188. package/src/user_data_portability 2.py +0 -328
  189. package/src/user_state_model 2.py +0 -170
  190. package/templates/CLAUDE.md 2.template +0 -108
  191. package/templates/CODEX.AGENTS.md 2.template +0 -66
  192. package/templates/launchagents/README 2.md +0 -132
  193. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
  194. package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
  195. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
  196. package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
  197. package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
  198. package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
  199. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
  200. package/templates/launchagents/com.nexo.immune 2.plist +0 -41
  201. package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
  202. package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
  203. package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
  204. package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
  205. package/templates/nexo_helper 2.py +0 -301
  206. package/templates/openclaw 2.json +0 -13
  207. package/templates/plugin-template 2.py +0 -40
  208. package/templates/script-template 2.py +0 -59
  209. package/templates/script-template 2.sh +0 -13
  210. package/templates/skill-script-template 2.py +0 -48
  211. package/templates/skill-template 2.md +0 -33
@@ -1,103 +0,0 @@
1
- from __future__ import annotations
2
- """Coordination tools: file tracking, messaging, Q&A."""
3
-
4
- from db import (
5
- track_files, untrack_files, get_all_tracked_files,
6
- send_message, get_inbox,
7
- ask_question, answer_question, get_pending_questions, check_answer,
8
- now_epoch,
9
- )
10
- from tools_sessions import _format_age
11
-
12
-
13
- def handle_track(sid: str, paths: list[str]) -> str:
14
- """Track files being edited. Reports conflicts immediately."""
15
- result = track_files(sid, paths)
16
- if "error" in result:
17
- return f"ERROR: {result['error']}"
18
-
19
- lines = [f"Tracked: {', '.join(result['tracked'])}"]
20
-
21
- if result["conflicts"]:
22
- lines.append("")
23
- lines.append("FILE CONFLICTS:")
24
- for c in result["conflicts"]:
25
- lines.append(f" {c['sid']} ({c['task']}):")
26
- for f in c["files"]:
27
- lines.append(f" {f}")
28
- lines.append("")
29
- lines.append("STOP and inform the user before editing.")
30
-
31
- return "\n".join(lines)
32
-
33
-
34
- def handle_untrack(sid: str, paths: list[str] | None = None) -> str:
35
- """Untrack files. If no paths given, untrack all."""
36
- untrack_files(sid, paths)
37
- if paths:
38
- return f"Untracked: {', '.join(paths)}"
39
- return "All files released."
40
-
41
-
42
- def handle_files() -> str:
43
- """Show all tracked files across sessions."""
44
- data = get_all_tracked_files()
45
- if not data:
46
- return "No tracked files."
47
-
48
- lines = ["TRACKED FILES:"]
49
- all_paths = {}
50
- for sid, info in data.items():
51
- for path in info["files"]:
52
- all_paths.setdefault(path, []).append(sid)
53
- lines.append(f" {sid} ({info['task']}):")
54
- for path in info["files"]:
55
- lines.append(f" {path}")
56
-
57
- conflicts = {p: sids for p, sids in all_paths.items() if len(sids) > 1}
58
- if conflicts:
59
- lines.append("")
60
- lines.append("CONFLICTS:")
61
- for path, sids in conflicts.items():
62
- lines.append(f" {path} -> {', '.join(sids)}")
63
-
64
- return "\n".join(lines)
65
-
66
-
67
- def handle_send(from_sid: str, to_sid: str, text: str) -> str:
68
- """Send a message. to_sid='all' for broadcast."""
69
- msg_id = send_message(from_sid, to_sid, text)
70
- target = "all sessions" if to_sid == "all" else to_sid
71
- return f"Message {msg_id} sent to {target}."
72
-
73
-
74
- def handle_ask(from_sid: str, to_sid: str, question: str) -> str:
75
- """Create a question to another session (non-blocking)."""
76
- qid = ask_question(from_sid, to_sid, question)
77
- return (
78
- f"Question sent: {qid}\n"
79
- f"To: {to_sid}\n"
80
- f"Question: {question}\n\n"
81
- f"The other session will see this question on its next nexo_heartbeat.\n"
82
- f"Use nexo_check_answer(qid='{qid}') to check for a response."
83
- )
84
-
85
-
86
- def handle_answer(qid: str, answer_text: str) -> str:
87
- """Answer a pending question."""
88
- result = answer_question(qid, answer_text)
89
- if "error" in result:
90
- return f"ERROR: {result['error']}"
91
- return f"Answered {qid}: {answer_text}"
92
-
93
-
94
- def handle_check_answer(qid: str) -> str:
95
- """Check if a question has been answered."""
96
- result = check_answer(qid)
97
- if not result:
98
- return f"Question {qid} not found."
99
- if result["status"] == "answered":
100
- return f"ANSWER for {qid}: {result['answer']}"
101
- elif result["status"] == "expired":
102
- return f"Question {qid} expired without answer."
103
- return f"Question still pending. Retry in a few seconds."
@@ -1,68 +0,0 @@
1
- """Credentials CRUD tools: get, create, update, delete, list."""
2
-
3
- from db import create_credential, update_credential, delete_credential, get_credential, list_credentials
4
-
5
-
6
- def handle_credential_get(service: str, key: str = '') -> str:
7
- """Retrieve credential(s) including their values. Use for reading secrets."""
8
- results = get_credential(service, key if key else None)
9
- if not results:
10
- target = f"{service}/{key}" if key else service
11
- return f"ERROR: No credentials found for '{target}'."
12
- is_fuzzy = any(r.get("_fuzzy") for r in results)
13
- lines = []
14
- if is_fuzzy:
15
- lines.append(f"⚠ No exact match for '{service}'. Similar results ({len(results)}):")
16
- lines.append("")
17
- for r in results:
18
- lines.append(f"CREDENTIAL {r['service']}/{r['key']}:")
19
- lines.append(f" Value: {r['value']}")
20
- notes = r.get("notes") or ""
21
- lines.append(f" Notes: {notes if notes else '—'}")
22
- return "\n".join(lines)
23
-
24
-
25
- def handle_credential_create(service: str, key: str, value: str, notes: str = '') -> str:
26
- """Create a new credential entry."""
27
- result = create_credential(service, key, value, notes)
28
- if "error" in result:
29
- return f"ERROR: {result['error']}"
30
- return f"Credential {service}/{key} created."
31
-
32
-
33
- def handle_credential_update(service: str, key: str, value: str = '', notes: str = '') -> str:
34
- """Update the value and/or notes of an existing credential."""
35
- result = update_credential(
36
- service,
37
- key,
38
- value if value else None,
39
- notes if notes else None,
40
- )
41
- if "error" in result:
42
- return f"ERROR: {result['error']}"
43
- return f"Credential {service}/{key} updated."
44
-
45
-
46
- def handle_credential_delete(service: str, key: str = '') -> str:
47
- """Delete a credential or all credentials for a service."""
48
- deleted = delete_credential(service, key if key else None)
49
- if not deleted:
50
- target = f"{service}/{key}" if key else service
51
- return f"ERROR: No credentials found for '{target}'."
52
- if key:
53
- return f"Credential deleted."
54
- return f"All credentials for service deleted."
55
-
56
-
57
- def handle_credential_list(service: str = '') -> str:
58
- """List credential service/key names and notes — values are never shown."""
59
- results = list_credentials(service if service else None)
60
- label = service if service else "ALL"
61
- if not results:
62
- return f"CREDENTIALS {label.upper()}: No entries."
63
- lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
64
- for r in results:
65
- notes = r.get("notes") or ""
66
- suffix = f" — {notes}" if notes else ""
67
- lines.append(f" {r['service']}/{r['key']}{suffix}")
68
- return "\n".join(lines)
@@ -1,487 +0,0 @@
1
- from __future__ import annotations
2
- """NEXO Drive/Curiosity — autonomous investigation signals.
3
-
4
- Public MCP tool handlers + internal detection logic that feeds from
5
- heartbeat, task_close, and diary consolidation.
6
- """
7
-
8
- import json
9
- import os
10
- import re
11
- import subprocess
12
- import time
13
- import unicodedata
14
-
15
- from db import (
16
- create_drive_signal, reinforce_drive_signal, get_drive_signals,
17
- get_drive_signal, update_drive_signal_status, decay_drive_signals,
18
- find_similar_drive_signal, drive_signal_stats,
19
- )
20
-
21
-
22
- # ── Semantic signal detection ────────────────────────────────────────
23
-
24
- # Primary path: concept-level semantic scoring with multilingual cue families.
25
- # Regex remains as explicit fallback only when the semantic scorer cannot
26
- # separate the classes with enough confidence.
27
-
28
- _SEMANTIC_THRESHOLD = 0.75
29
- _SEMANTIC_MARGIN = 0.15
30
- _LLM_MIN_TEXT_CHARS = int(os.environ.get("NEXO_DRIVE_LLM_MIN_CHARS", "24"))
31
- _LLM_TIMEOUT_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_TIMEOUT", "20"))
32
- _LLM_CONFIDENCE_THRESHOLD = float(os.environ.get("NEXO_DRIVE_LLM_CONFIDENCE", "0.62"))
33
- _LLM_CACHE_TTL_SECONDS = int(os.environ.get("NEXO_DRIVE_LLM_CACHE_TTL", "21600"))
34
- _LLM_ALLOWED_LABELS = {"anomaly", "pattern", "gap", "opportunity", "none"}
35
- _LLM_CLASSIFICATION_CACHE: dict[str, dict] = {}
36
-
37
- _SIGNAL_CUES = {
38
- "anomaly": {
39
- "metric": (
40
- "cpc", "ctr", "roas", "conversion", "conversiones", "revenue",
41
- "ingresos", "traffic", "trafico", "latency", "latencia",
42
- "error", "erro", "fehler", "failure", "fallo", "falla",
43
- "incident", "incidente", "kpi", "metric", "metrica",
44
- ),
45
- "change": (
46
- "subio*", "bajo*", "cayo*", "aumento*", "disminu*", "crecio*",
47
- "drop*", "spik*", "jump*", "rose", "fell", "grew", "surg*",
48
- "subiu*", "caiu*", "baixou*", "aumentou*", "stieg*", "fiel*",
49
- "gesunk*", "anstieg*", "einbruch*", "regression*",
50
- ),
51
- "unexpected": (
52
- "inesperad*", "unexpected*", "anom*", "raro*", "weird",
53
- "strange", "estranh*", "seltsam*", "ungewohn*", "anomalia*",
54
- "outlier*", "desviacion*", "abweich*",
55
- ),
56
- "degradation": (
57
- "degrad*", "timeout*", "slow*", "lento*", "caida*", "degraded",
58
- "down", "outage", "rot*", "broken", "rompio*", "broke",
59
- "schlecht*", "falha*", "incidencia*",
60
- ),
61
- },
62
- "pattern": {
63
- "recurrence": (
64
- "otra vez", "de nuevo", "again", "again and again", "recurr*",
65
- "repe*", "keeps happ*", "siempre pasa", "vuelve a pasar",
66
- "sempre", "sempre que", "de novo", "wieder", "immer wieder",
67
- "wiederholt*", "stuck in a loop", "reincid*",
68
- ),
69
- "cadence": (
70
- "cada vez que", "every time", "whenever", "cada semana",
71
- "cada mes", "once more", "toda vez que", "jedes mal",
72
- "wann immer", "all the time", "constantemente",
73
- ),
74
- "same_issue": (
75
- "mismo problema", "mismo error", "same problem", "same issue",
76
- "same error", "lo mismo", "same thing", "same blocker",
77
- "mesmo problema", "gleiches problem", "gleicher fehler",
78
- ),
79
- },
80
- "gap": {
81
- "uncertainty": (
82
- "no se como", "no entiendo", "no tengo claro", "unclear how",
83
- "dont know how", "not sure how", "i do not know how",
84
- "sem saber como", "nao sei como", "ich weiss nicht wie",
85
- "ich weiß nicht wie", "unklar wie", "blocked by not knowing",
86
- ),
87
- "missing_knowledge": (
88
- "falta documentacion", "missing docs", "missing documentation",
89
- "undocumented", "not documented", "sin documentar", "sin guia",
90
- "no hay runbook", "no hay playbook", "sem documentacao",
91
- "fehlt dokumentation", "kein runbook", "unknown process",
92
- ),
93
- "blocked_execution": (
94
- "bloqueado porque", "blocked because", "cannot proceed",
95
- "no puedo seguir", "cant continue", "nao consigo avanzar",
96
- "komme nicht weiter", "stuck because",
97
- ),
98
- },
99
- "opportunity": {
100
- "benchmark_gap": (
101
- "media del sector", "industry average", "below peers",
102
- "por debajo", "underperform*", "lagging", "low compared",
103
- "abaixo do benchmark", "unter benchmark", "unter dem schnitt",
104
- ),
105
- "improvement": (
106
- "automatiz*", "optimiz*", "mejor*", "improv*", "streamlin*",
107
- "simplif*", "scale*", "accelerat*", "reduce manual",
108
- "automat*", "melhor*", "verbesser*", "effizien*",
109
- ),
110
- "potential": (
111
- "podriamos", "se podria", "could", "we could", "opportunity",
112
- "worth exploring", "room to", "potencial", "oportunidade",
113
- "chance to", "could unlock", "konnten", "man koennte",
114
- ),
115
- },
116
- }
117
-
118
- _SIGNAL_FAMILY_WEIGHTS = {
119
- "anomaly": {"metric": 0.28, "change": 0.38, "unexpected": 0.30, "degradation": 0.28},
120
- "pattern": {"recurrence": 0.36, "cadence": 0.34, "same_issue": 0.34},
121
- "gap": {"uncertainty": 0.78, "missing_knowledge": 0.52, "blocked_execution": 0.36},
122
- "opportunity": {"benchmark_gap": 0.78, "improvement": 0.38, "potential": 0.32},
123
- }
124
-
125
- _FALLBACK_PATTERNS = {
126
- "anomaly": (
127
- re.compile(r"\b(subió|bajó|cayó|dropped|spiked|jumped)\b.*\b\d+%", re.I),
128
- re.compile(r"\b(inesperado|unexpected|anomal|raro|weird|strange)\b", re.I),
129
- ),
130
- "pattern": (
131
- re.compile(r"\b(otra vez|again|de nuevo|siempre pasa|keeps happening|recurring)\b", re.I),
132
- re.compile(r"\b(cada vez que|every time|whenever)\b", re.I),
133
- ),
134
- "gap": (
135
- re.compile(r"\b(no sé cómo|don'?t know how|no entiendo|unclear how)\b", re.I),
136
- re.compile(r"\b(falta documentación|missing docs|undocumented)\b", re.I),
137
- ),
138
- "opportunity": (
139
- re.compile(r"\b(benchmark|media del sector|industry average)\b.*\b(bajo|low|por debajo|below)\b", re.I),
140
- re.compile(r"\b(podríamos|could|se podría|we could|opportunity)\b.*\b(automatiz|improve|mejorar|optimiz)\b", re.I),
141
- ),
142
- }
143
-
144
-
145
- def _normalize_text(text: str) -> str:
146
- lowered = (text or "").lower().replace("ß", "ss").replace("'", "")
147
- lowered = unicodedata.normalize("NFKD", lowered)
148
- lowered = "".join(ch for ch in lowered if not unicodedata.combining(ch))
149
- lowered = re.sub(r"[^a-z0-9%+\s]", " ", lowered)
150
- return re.sub(r"\s+", " ", lowered).strip()
151
-
152
-
153
- def _extract_json_object(raw: str) -> dict | None:
154
- text = (raw or "").strip()
155
- if not text:
156
- return None
157
- try:
158
- payload = json.loads(text)
159
- return payload if isinstance(payload, dict) else None
160
- except json.JSONDecodeError:
161
- pass
162
-
163
- start = text.find("{")
164
- end = text.rfind("}")
165
- if start == -1 or end == -1 or end <= start:
166
- return None
167
- try:
168
- payload = json.loads(text[start : end + 1])
169
- return payload if isinstance(payload, dict) else None
170
- except json.JSONDecodeError:
171
- return None
172
-
173
-
174
- def _tokenize(text: str) -> list[str]:
175
- return [token for token in text.split() if token]
176
-
177
-
178
- def _matches_cue(cue: str, text_norm: str, tokens: list[str]) -> bool:
179
- cue_norm = _normalize_text(cue)
180
- if not cue_norm:
181
- return False
182
- if cue_norm.endswith("*"):
183
- stem = cue_norm[:-1]
184
- return bool(stem) and any(token.startswith(stem) for token in tokens)
185
- if " " in cue_norm:
186
- return cue_norm in text_norm
187
- return cue_norm in tokens or any(token.startswith(cue_norm) for token in tokens if len(cue_norm) >= 5)
188
-
189
-
190
- def _has_numeric_signal(tokens: list[str]) -> bool:
191
- for token in tokens:
192
- raw = token.rstrip("%")
193
- try:
194
- float(raw)
195
- return True
196
- except ValueError:
197
- continue
198
- return False
199
-
200
-
201
- def _semantic_signal_scores(text: str) -> dict[str, float]:
202
- text_norm = _normalize_text(text)
203
- tokens = _tokenize(text_norm)
204
- if not tokens:
205
- return {}
206
-
207
- numeric_signal = _has_numeric_signal(tokens)
208
- scores = {signal_type: 0.0 for signal_type in _SIGNAL_CUES}
209
- family_hits: dict[str, set[str]] = {signal_type: set() for signal_type in _SIGNAL_CUES}
210
-
211
- for signal_type, families in _SIGNAL_CUES.items():
212
- weights = _SIGNAL_FAMILY_WEIGHTS[signal_type]
213
- for family_name, cues in families.items():
214
- matches = [cue for cue in cues if _matches_cue(cue, text_norm, tokens)]
215
- if not matches:
216
- continue
217
- family_hits[signal_type].add(family_name)
218
- bonus = min(0.12, 0.04 * max(0, len(matches) - 1))
219
- scores[signal_type] += weights[family_name] + bonus
220
-
221
- anomaly_hits = family_hits["anomaly"]
222
- if "metric" in anomaly_hits and "change" in anomaly_hits:
223
- scores["anomaly"] += 0.22
224
- if numeric_signal and ("change" in anomaly_hits or "metric" in anomaly_hits):
225
- scores["anomaly"] += 0.14
226
- if "unexpected" in anomaly_hits and ("change" in anomaly_hits or "degradation" in anomaly_hits):
227
- scores["anomaly"] += 0.12
228
- if "unexpected" in anomaly_hits and "metric" in anomaly_hits:
229
- scores["anomaly"] += 0.10
230
-
231
- pattern_hits = family_hits["pattern"]
232
- if "recurrence" in pattern_hits and ("cadence" in pattern_hits or "same_issue" in pattern_hits):
233
- scores["pattern"] += 0.18
234
-
235
- gap_hits = family_hits["gap"]
236
- if "uncertainty" in gap_hits and ("missing_knowledge" in gap_hits or "blocked_execution" in gap_hits):
237
- scores["gap"] += 0.18
238
-
239
- opportunity_hits = family_hits["opportunity"]
240
- if "benchmark_gap" in opportunity_hits:
241
- scores["opportunity"] += 0.16
242
- if "improvement" in opportunity_hits and "potential" in opportunity_hits:
243
- scores["opportunity"] += 0.18
244
-
245
- return scores
246
-
247
-
248
- def _llm_cache_key(text: str) -> str:
249
- return _normalize_text(text)[:1200]
250
-
251
-
252
- def _llm_classify_signal(text: str) -> dict:
253
- text_norm = _normalize_text(text)
254
- if len(text_norm) < _LLM_MIN_TEXT_CHARS:
255
- return {"available": False, "label": None, "reason": "text_too_short"}
256
-
257
- cache_key = _llm_cache_key(text)
258
- now = time.time()
259
- cached = _LLM_CLASSIFICATION_CACHE.get(cache_key)
260
- if cached and cached.get("expires_at", 0) > now:
261
- return {k: v for k, v in cached.items() if k != "expires_at"}
262
-
263
- try:
264
- from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
265
- except Exception as exc:
266
- return {"available": False, "label": None, "reason": f"runner_unavailable:{exc}"}
267
-
268
- json_system_prompt = (
269
- "You classify operational text into one of exactly five labels: "
270
- "anomaly, pattern, gap, opportunity, none. "
271
- "Return ONLY a valid JSON object with keys: label, confidence, reason. "
272
- "confidence must be a number from 0 to 1. "
273
- "Use anomaly for unexpected changes/degradation, pattern for recurrence, "
274
- "gap for missing knowledge/documentation/blocker, opportunity for improvement/automation/benchmark gaps, "
275
- "none when the text is normal progress without a useful signal."
276
- )
277
- prompt = (
278
- "Classify this NEXO Drive signal candidate.\n\n"
279
- f"TEXT:\n{text.strip()[:3000]}\n\n"
280
- "Return JSON only."
281
- )
282
-
283
- try:
284
- result = run_automation_prompt(
285
- prompt,
286
- task_profile="fast",
287
- timeout=_LLM_TIMEOUT_SECONDS,
288
- output_format="text",
289
- append_system_prompt=json_system_prompt,
290
- )
291
- except (AutomationBackendUnavailableError, subprocess.TimeoutExpired) as exc:
292
- return {"available": False, "label": None, "reason": f"automation_unavailable:{exc}"}
293
- except Exception as exc:
294
- return {"available": False, "label": None, "reason": f"automation_error:{exc}"}
295
-
296
- if result.returncode != 0:
297
- return {"available": False, "label": None, "reason": f"automation_returncode:{result.returncode}"}
298
-
299
- parsed = _extract_json_object(result.stdout)
300
- if not parsed:
301
- return {"available": False, "label": None, "reason": "invalid_json"}
302
-
303
- label = str(parsed.get("label", "") or "").strip().lower()
304
- if label not in _LLM_ALLOWED_LABELS:
305
- return {"available": False, "label": None, "reason": "invalid_label"}
306
-
307
- try:
308
- confidence = float(parsed.get("confidence", 0.0) or 0.0)
309
- except (TypeError, ValueError):
310
- confidence = 0.0
311
-
312
- classification = {
313
- "available": True,
314
- "label": None if label == "none" else label,
315
- "confidence": confidence,
316
- "reason": str(parsed.get("reason", "") or ""),
317
- "source": "llm",
318
- }
319
- _LLM_CLASSIFICATION_CACHE[cache_key] = {
320
- **classification,
321
- "expires_at": now + _LLM_CACHE_TTL_SECONDS,
322
- }
323
- return classification
324
-
325
-
326
- def _regex_fallback_classify(text: str) -> str | None:
327
- for signal_type, patterns in _FALLBACK_PATTERNS.items():
328
- if any(pattern.search(text) for pattern in patterns):
329
- return signal_type
330
- return None
331
-
332
-
333
- def _classify_signal(text: str, *, allow_llm: bool = True) -> str | None:
334
- """Classify text into a signal type, or None if nothing interesting."""
335
- if allow_llm:
336
- llm_result = _llm_classify_signal(text)
337
- if llm_result.get("available"):
338
- confidence = float(llm_result.get("confidence", 0.0) or 0.0)
339
- label = llm_result.get("label")
340
- if label is None and confidence >= _LLM_CONFIDENCE_THRESHOLD:
341
- return None
342
- if isinstance(label, str) and confidence >= _LLM_CONFIDENCE_THRESHOLD:
343
- return label
344
-
345
- scores = _semantic_signal_scores(text)
346
- if scores:
347
- ordered = sorted(scores.items(), key=lambda item: item[1], reverse=True)
348
- winner, winner_score = ordered[0]
349
- runner_up = ordered[1][1] if len(ordered) > 1 else 0.0
350
- if winner_score >= _SEMANTIC_THRESHOLD and (winner_score - runner_up) >= _SEMANTIC_MARGIN:
351
- return winner
352
- if winner_score >= 0.35:
353
- return None
354
- return _regex_fallback_classify(text)
355
-
356
-
357
- def _infer_area(text: str) -> str:
358
- """Infer operational area from text keywords."""
359
- text_lower = text.lower()
360
- area_keywords = {
361
- "shopify": ["shopify", "tienda", "pedido", "producto", "sku"],
362
- "google-ads": ["google ads", "campaña", "campaign", "cpc", "pmax", "roas", "gads"],
363
- "meta-ads": ["meta ads", "facebook", "instagram", "pixel", "capi"],
364
- "wazion": ["wazion", "whatsapp", "wa ", "baileys"],
365
- "nexo": ["nexo", "brain", "mcp", "cognitive"],
366
- "canaririural": ["canarirural", "canari", "reserva", "hospedaje", "alojamiento", "propietario"],
367
- "seo": ["seo", "search console", "indexación", "ranking"],
368
- "email": ["email", "correo", "inbox", "smtp"],
369
- }
370
- for area, keywords in area_keywords.items():
371
- for kw in keywords:
372
- if kw in text_lower:
373
- return area
374
- return ""
375
-
376
-
377
- def detect_drive_signal(
378
- context_hint: str,
379
- source: str,
380
- source_id: str = "",
381
- area: str = "",
382
- *,
383
- allow_llm: bool = False,
384
- ) -> dict | None:
385
- """Analyze text for interesting signals. Creates or reinforces.
386
-
387
- Called internally from heartbeat and task_close. Not a public MCP tool.
388
- Returns the signal dict if created/reinforced, None otherwise.
389
- """
390
- if not context_hint or len(context_hint.strip()) < 15:
391
- return None
392
-
393
- signal_type = _classify_signal(context_hint, allow_llm=allow_llm)
394
- if not signal_type:
395
- return None
396
-
397
- inferred_area = area or _infer_area(context_hint)
398
-
399
- # Check for similar existing signal
400
- existing = find_similar_drive_signal(context_hint, inferred_area)
401
- if existing:
402
- result = reinforce_drive_signal(existing["id"], context_hint[:500])
403
- return result if result.get("ok") else None
404
-
405
- # Create new
406
- result = create_drive_signal(
407
- signal_type=signal_type,
408
- source=source,
409
- source_id=source_id,
410
- area=inferred_area,
411
- summary=context_hint[:300],
412
- )
413
- return result if result.get("ok") else None
414
-
415
-
416
- # ── Public MCP tool handlers ─────────────────────────────────────────
417
-
418
- def handle_drive_signals(
419
- status: str = "",
420
- area: str = "",
421
- limit: int = 20,
422
- ) -> str:
423
- """List drive signals, optionally filtered by status and area."""
424
- signals = get_drive_signals(
425
- status=status or None,
426
- area=area or None,
427
- limit=limit,
428
- )
429
- if not signals:
430
- return "No drive signals found."
431
-
432
- stats = drive_signal_stats()
433
- lines = [
434
- f"DRIVE SIGNALS ({len(signals)} shown, {stats['total']} total):",
435
- f" By status: {json.dumps(stats.get('by_status', {}), ensure_ascii=False)}",
436
- "",
437
- ]
438
- for s in signals:
439
- evidence_count = 0
440
- try:
441
- evidence_count = len(json.loads(s.get("evidence") or "[]"))
442
- except (json.JSONDecodeError, TypeError):
443
- pass
444
- tension_bar = "█" * int(float(s.get("tension", 0)) * 10)
445
- lines.append(
446
- f" [{s['id']}] {s['status'].upper()} {tension_bar} "
447
- f"t={s['tension']:.2f} ({s['signal_type']}) "
448
- f"{'[' + s['area'] + '] ' if s.get('area') else ''}"
449
- f"{s['summary'][:80]}"
450
- f" ({evidence_count} obs, decay={s.get('decay_rate', 0.05):.2f})"
451
- )
452
- return "\n".join(lines)
453
-
454
-
455
- def handle_drive_reinforce(signal_id: int, observation: str) -> str:
456
- """Manually reinforce a drive signal with a new observation."""
457
- if not observation.strip():
458
- return "ERROR: observation cannot be empty"
459
- result = reinforce_drive_signal(signal_id, observation)
460
- if not result.get("ok"):
461
- return f"ERROR: {result.get('error', 'unknown')}"
462
- return (
463
- f"Signal #{signal_id} reinforced: "
464
- f"tension {result['old_tension']:.2f} → {result['new_tension']:.2f}, "
465
- f"status {result['old_status']} → {result['new_status']}, "
466
- f"{result['evidence_count']} observations total"
467
- )
468
-
469
-
470
- def handle_drive_act(signal_id: int, outcome: str) -> str:
471
- """Mark a drive signal as investigated with an outcome."""
472
- if not outcome.strip():
473
- return "ERROR: outcome cannot be empty"
474
- result = update_drive_signal_status(signal_id, "acted", outcome)
475
- if not result.get("ok"):
476
- return f"ERROR: {result.get('error', 'unknown')}"
477
- return f"Signal #{signal_id} marked as ACTED. Outcome recorded."
478
-
479
-
480
- def handle_drive_dismiss(signal_id: int, reason: str) -> str:
481
- """Dismiss a drive signal with a reason (archived, not deleted)."""
482
- if not reason.strip():
483
- return "ERROR: reason cannot be empty"
484
- result = update_drive_signal_status(signal_id, "dismissed", reason)
485
- if not result.get("ok"):
486
- return f"ERROR: {result.get('error', 'unknown')}"
487
- return f"Signal #{signal_id} dismissed. Reason: {reason}"