nexo-brain 5.3.26 → 5.3.28

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 (212) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/hook_guardrails.py +44 -0
  4. package/src/server.py +3 -0
  5. package/src/tools_sessions.py +6 -1
  6. package/src/dashboard/static/favicon 2.svg +0 -32
  7. package/src/dashboard/static/nexo-logo 2.png +0 -0
  8. package/src/dashboard/static/nexo-logo 2.svg +0 -40
  9. package/src/dashboard/static/style 2.css +0 -2458
  10. package/src/dashboard/templates/adaptive 2.html +0 -118
  11. package/src/dashboard/templates/artifacts 2.html +0 -133
  12. package/src/dashboard/templates/backups 2.html +0 -136
  13. package/src/dashboard/templates/base 2.html +0 -417
  14. package/src/dashboard/templates/calendar 2.html +0 -591
  15. package/src/dashboard/templates/chat 2.html +0 -356
  16. package/src/dashboard/templates/claims 2.html +0 -259
  17. package/src/dashboard/templates/cortex 2.html +0 -321
  18. package/src/dashboard/templates/credentials 2.html +0 -128
  19. package/src/dashboard/templates/crons 2.html +0 -370
  20. package/src/dashboard/templates/dashboard 2.html +0 -494
  21. package/src/dashboard/templates/dreams 2.html +0 -252
  22. package/src/dashboard/templates/email 2.html +0 -160
  23. package/src/dashboard/templates/evolution 2.html +0 -189
  24. package/src/dashboard/templates/feed 2.html +0 -249
  25. package/src/dashboard/templates/followup_health 2.html +0 -170
  26. package/src/dashboard/templates/graph 2.html +0 -201
  27. package/src/dashboard/templates/guard 2.html +0 -259
  28. package/src/dashboard/templates/inbox 2.html +0 -251
  29. package/src/dashboard/templates/memory 2.html +0 -420
  30. package/src/dashboard/templates/operations 2.html +0 -608
  31. package/src/dashboard/templates/plugins 2.html +0 -185
  32. package/src/dashboard/templates/protocol 2.html +0 -199
  33. package/src/dashboard/templates/rules 2.html +0 -246
  34. package/src/dashboard/templates/sentiment 2.html +0 -247
  35. package/src/dashboard/templates/sessions 2.html +0 -218
  36. package/src/dashboard/templates/skills 2.html +0 -329
  37. package/src/dashboard/templates/somatic 2.html +0 -73
  38. package/src/dashboard/templates/triggers 2.html +0 -133
  39. package/src/dashboard/templates/trust 2.html +0 -360
  40. package/src/db/__init__ 2.py +0 -259
  41. package/src/db/_core 2.py +0 -437
  42. package/src/db/_credentials 2.py +0 -124
  43. package/src/db/_episodic 2.py +0 -762
  44. package/src/db/_evolution 2.py +0 -54
  45. package/src/db/_fts 2.py +0 -406
  46. package/src/db/_goal_profiles 2.py +0 -376
  47. package/src/db/_hot_context 2.py +0 -660
  48. package/src/db/_outcomes 2.py +0 -800
  49. package/src/db/_personal_scripts 2.py +0 -582
  50. package/src/db/_sessions 2.py +0 -330
  51. package/src/db/_tasks 2.py +0 -91
  52. package/src/db/_watchers 2.py +0 -173
  53. package/src/doctor/formatters 2.py +0 -52
  54. package/src/doctor/models 2.py +0 -69
  55. package/src/doctor/planes 2.py +0 -87
  56. package/src/doctor/providers/__init__ 2.py +0 -1
  57. package/src/doctor/providers/deep 2.py +0 -367
  58. package/src/evolution_cycle 2.py +0 -519
  59. package/src/hooks/auto_capture 2.py +0 -208
  60. package/src/hooks/caffeinate-guard 2.sh +0 -8
  61. package/src/hooks/capture-session 2.sh +0 -21
  62. package/src/hooks/capture-tool-logs 2.sh +0 -158
  63. package/src/hooks/daily-briefing-check 2.sh +0 -33
  64. package/src/hooks/heartbeat-enforcement 2.py +0 -90
  65. package/src/hooks/heartbeat-posttool 2.sh +0 -18
  66. package/src/hooks/inbox-hook 2.sh +0 -76
  67. package/src/hooks/post-compact 2.sh +0 -152
  68. package/src/hooks/pre-compact 2.sh +0 -169
  69. package/src/hooks/protocol-guardrail 2.sh +0 -10
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +0 -9
  71. package/src/hooks/session-stop 2.sh +0 -52
  72. package/src/kg_populate 2.py +0 -292
  73. package/src/maintenance 2.py +0 -53
  74. package/src/memory_backends 2.py +0 -71
  75. package/src/migrate_embeddings 2.py +0 -124
  76. package/src/nexo_sdk 2.py +0 -103
  77. package/src/observability 2.py +0 -199
  78. package/src/plugin_loader 2.py +0 -217
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/artifact_registry 2.py +0 -450
  81. package/src/plugins/backup 2.py +0 -127
  82. package/src/plugins/claims_tools 2.py +0 -119
  83. package/src/plugins/cognitive_memory 2.py +0 -609
  84. package/src/plugins/core_rules 2.py +0 -252
  85. package/src/plugins/cortex 2.py +0 -1155
  86. package/src/plugins/entities 2.py +0 -67
  87. package/src/plugins/episodic_memory 2.py +0 -560
  88. package/src/plugins/evolution 2.py +0 -167
  89. package/src/plugins/goal_engine 2.py +0 -142
  90. package/src/plugins/guard 2.py +0 -862
  91. package/src/plugins/impact 2.py +0 -29
  92. package/src/plugins/knowledge_graph_tools 2.py +0 -137
  93. package/src/plugins/media_memory_tools 2.py +0 -98
  94. package/src/plugins/memory_export 2.py +0 -196
  95. package/src/plugins/outcomes 2.py +0 -130
  96. package/src/plugins/personal_scripts 2.py +0 -117
  97. package/src/plugins/preferences 2.py +0 -47
  98. package/src/plugins/protocol 2.py +0 -1449
  99. package/src/plugins/simple_api 2.py +0 -106
  100. package/src/plugins/skills 2.py +0 -341
  101. package/src/plugins/state_watchers 2.py +0 -79
  102. package/src/plugins/update 2.py +0 -986
  103. package/src/plugins/user_state_tools 2.py +0 -43
  104. package/src/plugins/workflow 2.py +0 -588
  105. package/src/protocol_settings 2.py +0 -59
  106. package/src/public_contribution 2.py +0 -466
  107. package/src/public_evolution_queue 2.py +0 -241
  108. package/src/requirements 2.txt +0 -14
  109. package/src/retroactive_learnings 2.py +0 -373
  110. package/src/rules/__init__ 2.py +0 -0
  111. package/src/rules/core-rules 2.json +0 -331
  112. package/src/rules/migrate 2.py +0 -207
  113. package/src/runtime_power 2.py +0 -874
  114. package/src/script_registry 2.py +0 -1559
  115. package/src/scripts/check-context 2.py +0 -272
  116. package/src/scripts/deep-sleep/apply_findings 2.py +0 -2327
  117. package/src/scripts/deep-sleep/collect 2.py +0 -928
  118. package/src/scripts/deep-sleep/extract 2.py +0 -330
  119. package/src/scripts/deep-sleep/extract-prompt 2.md +0 -285
  120. package/src/scripts/deep-sleep/synthesize 2.py +0 -312
  121. package/src/scripts/deep-sleep/synthesize-prompt 2.md +0 -336
  122. package/src/scripts/nexo-agent-run 2.py +0 -75
  123. package/src/scripts/nexo-auto-update 2.py +0 -6
  124. package/src/scripts/nexo-backup 2.sh +0 -25
  125. package/src/scripts/nexo-brain-activation 2.sh +0 -140
  126. package/src/scripts/nexo-catchup 2.py +0 -300
  127. package/src/scripts/nexo-cognitive-decay 2.py +0 -257
  128. package/src/scripts/nexo-cortex-cycle 2.py +0 -293
  129. package/src/scripts/nexo-cron-wrapper 2.sh +0 -53
  130. package/src/scripts/nexo-daily-self-audit 2.py +0 -2161
  131. package/src/scripts/nexo-dashboard 2.sh +0 -29
  132. package/src/scripts/nexo-deep-sleep 2.sh +0 -86
  133. package/src/scripts/nexo-evolution-run 2.py +0 -1664
  134. package/src/scripts/nexo-followup-hygiene 2.py +0 -139
  135. package/src/scripts/nexo-hook-record 2.py +0 -42
  136. package/src/scripts/nexo-immune 2.py +0 -936
  137. package/src/scripts/nexo-impact-scorer 2.py +0 -117
  138. package/src/scripts/nexo-inbox-hook 2.sh +0 -74
  139. package/src/scripts/nexo-install 2.py +0 -6
  140. package/src/scripts/nexo-learning-housekeep 2.py +0 -401
  141. package/src/scripts/nexo-learning-validator 2.py +0 -266
  142. package/src/scripts/nexo-migrate 2.py +0 -260
  143. package/src/scripts/nexo-outcome-checker 2.py +0 -127
  144. package/src/scripts/nexo-postmortem-consolidator 2.py +0 -456
  145. package/src/scripts/nexo-pre-commit 2.py +0 -120
  146. package/src/scripts/nexo-prevent-sleep 2.sh +0 -35
  147. package/src/scripts/nexo-proactive-dashboard 2.py +0 -354
  148. package/src/scripts/nexo-reflection 2.py +0 -256
  149. package/src/scripts/nexo-runtime-preflight 2.py +0 -274
  150. package/src/scripts/nexo-sleep 2.py +0 -631
  151. package/src/scripts/nexo-snapshot-restore 2.sh +0 -35
  152. package/src/scripts/nexo-sync-clients 2.py +0 -16
  153. package/src/scripts/nexo-synthesis 2.py +0 -475
  154. package/src/scripts/nexo-tcc-approve 2.sh +0 -79
  155. package/src/scripts/nexo-update 2.sh +0 -306
  156. package/src/scripts/nexo-watchdog 2.sh +0 -1207
  157. package/src/scripts/nexo-watchdog-smoke 2.py +0 -119
  158. package/src/scripts/rehydrate_learnings_from_archive 2.py +0 -245
  159. package/src/server 2.py +0 -1296
  160. package/src/skills/run-nexo-audit-phase/guide 2.md +0 -43
  161. package/src/skills/run-nexo-audit-phase/skill 2.json +0 -59
  162. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +0 -17
  163. package/src/skills/run-nexo-core-fix-cycle/script 2.py +0 -276
  164. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +0 -58
  165. package/src/skills/run-release-final-audit/guide 2.md +0 -16
  166. package/src/skills/run-release-final-audit/script 2.py +0 -259
  167. package/src/skills/run-release-final-audit/skill 2.json +0 -77
  168. package/src/skills/run-runtime-doctor/guide 2.md +0 -12
  169. package/src/skills/run-runtime-doctor/script 2.py +0 -21
  170. package/src/skills/run-runtime-doctor/skill 2.json +0 -25
  171. package/src/skills_runtime 2.py +0 -932
  172. package/src/state_watchers_runtime 2.py +0 -475
  173. package/src/storage_router 2.py +0 -32
  174. package/src/system_catalog 2.py +0 -786
  175. package/src/tools_coordination 2.py +0 -103
  176. package/src/tools_credentials 2.py +0 -68
  177. package/src/tools_drive 2.py +0 -487
  178. package/src/tools_hot_context 2.py +0 -163
  179. package/src/tools_learnings 2.py +0 -612
  180. package/src/tools_menu 2.py +0 -229
  181. package/src/tools_reminders 2.py +0 -88
  182. package/src/tools_reminders_crud 2.py +0 -363
  183. package/src/tools_sessions 2.py +0 -1054
  184. package/src/tools_system_catalog 2.py +0 -19
  185. package/src/tools_task_history 2.py +0 -57
  186. package/src/tools_transcripts 2.py +0 -98
  187. package/src/transcript_utils 2.py +0 -412
  188. package/src/user_context 2.py +0 -46
  189. package/src/user_data_portability 2.py +0 -328
  190. package/src/user_state_model 2.py +0 -170
  191. package/templates/CLAUDE.md 2.template +0 -108
  192. package/templates/CODEX.AGENTS.md 2.template +0 -66
  193. package/templates/launchagents/README 2.md +0 -132
  194. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +0 -39
  195. package/templates/launchagents/com.nexo.catchup 2.plist +0 -39
  196. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +0 -40
  197. package/templates/launchagents/com.nexo.dashboard 2.plist +0 -43
  198. package/templates/launchagents/com.nexo.deep-sleep 2.plist +0 -43
  199. package/templates/launchagents/com.nexo.evolution 2.plist +0 -44
  200. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +0 -45
  201. package/templates/launchagents/com.nexo.immune 2.plist +0 -41
  202. package/templates/launchagents/com.nexo.postmortem 2.plist +0 -45
  203. package/templates/launchagents/com.nexo.self-audit 2.plist +0 -47
  204. package/templates/launchagents/com.nexo.synthesis 2.plist +0 -45
  205. package/templates/launchagents/com.nexo.watchdog 2.plist +0 -37
  206. package/templates/nexo_helper 2.py +0 -301
  207. package/templates/openclaw 2.json +0 -13
  208. package/templates/plugin-template 2.py +0 -40
  209. package/templates/script-template 2.py +0 -59
  210. package/templates/script-template 2.sh +0 -13
  211. package/templates/skill-script-template 2.py +0 -48
  212. 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}"