nexo-brain 5.3.13 → 5.3.15

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 (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -1
  3. package/package.json +1 -1
  4. package/src/crons/sync.py +18 -4
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_entities.py +1 -1
  43. package/src/db/_episodic 2.py +762 -0
  44. package/src/db/_evolution 2.py +54 -0
  45. package/src/db/_fts 2.py +406 -0
  46. package/src/db/_goal_profiles 2.py +376 -0
  47. package/src/db/_hot_context 2.py +660 -0
  48. package/src/db/_outcomes 2.py +800 -0
  49. package/src/db/_personal_scripts 2.py +582 -0
  50. package/src/db/_sessions 2.py +330 -0
  51. package/src/db/_tasks 2.py +91 -0
  52. package/src/db/_watchers 2.py +173 -0
  53. package/src/doctor/formatters 2.py +52 -0
  54. package/src/doctor/models 2.py +69 -0
  55. package/src/doctor/planes 2.py +87 -0
  56. package/src/doctor/providers/__init__ 2.py +1 -0
  57. package/src/doctor/providers/deep 2.py +367 -0
  58. package/src/evolution_cycle 2.py +519 -0
  59. package/src/hooks/auto_capture 2.py +208 -0
  60. package/src/hooks/caffeinate-guard 2.sh +8 -0
  61. package/src/hooks/capture-session 2.sh +21 -0
  62. package/src/hooks/capture-tool-logs 2.sh +158 -0
  63. package/src/hooks/daily-briefing-check 2.sh +33 -0
  64. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  65. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  66. package/src/hooks/inbox-hook 2.sh +76 -0
  67. package/src/hooks/post-compact 2.sh +152 -0
  68. package/src/hooks/pre-compact 2.sh +169 -0
  69. package/src/hooks/protocol-guardrail 2.sh +10 -0
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  71. package/src/hooks/session-stop 2.sh +52 -0
  72. package/src/kg_populate 2.py +292 -0
  73. package/src/maintenance 2.py +53 -0
  74. package/src/memory_backends 2.py +71 -0
  75. package/src/migrate_embeddings 2.py +124 -0
  76. package/src/nexo_sdk 2.py +103 -0
  77. package/src/observability 2.py +199 -0
  78. package/src/plugin_loader 2.py +217 -0
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/agents.py +10 -3
  81. package/src/plugins/artifact_registry 2.py +450 -0
  82. package/src/plugins/backup 2.py +127 -0
  83. package/src/plugins/claims_tools 2.py +119 -0
  84. package/src/plugins/cognitive_memory 2.py +609 -0
  85. package/src/plugins/core_rules 2.py +252 -0
  86. package/src/plugins/cortex 2.py +1155 -0
  87. package/src/plugins/entities 2.py +67 -0
  88. package/src/plugins/episodic_memory 2.py +560 -0
  89. package/src/plugins/evolution 2.py +167 -0
  90. package/src/plugins/goal_engine 2.py +142 -0
  91. package/src/plugins/guard 2.py +862 -0
  92. package/src/plugins/impact 2.py +29 -0
  93. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  94. package/src/plugins/media_memory_tools 2.py +98 -0
  95. package/src/plugins/memory_export 2.py +196 -0
  96. package/src/plugins/outcomes 2.py +130 -0
  97. package/src/plugins/personal_scripts 2.py +117 -0
  98. package/src/plugins/preferences 2.py +47 -0
  99. package/src/plugins/protocol 2.py +1449 -0
  100. package/src/plugins/schedule.py +2 -1
  101. package/src/plugins/simple_api 2.py +106 -0
  102. package/src/plugins/skills 2.py +341 -0
  103. package/src/plugins/state_watchers 2.py +79 -0
  104. package/src/plugins/update 2.py +986 -0
  105. package/src/plugins/user_state_tools 2.py +43 -0
  106. package/src/plugins/workflow 2.py +588 -0
  107. package/src/protocol_settings 2.py +59 -0
  108. package/src/public_contribution 2.py +466 -0
  109. package/src/public_evolution_queue 2.py +241 -0
  110. package/src/requirements 2.txt +14 -0
  111. package/src/requirements.txt +1 -1
  112. package/src/retroactive_learnings 2.py +373 -0
  113. package/src/rules/__init__ 2.py +0 -0
  114. package/src/rules/core-rules 2.json +331 -0
  115. package/src/rules/migrate 2.py +207 -0
  116. package/src/runtime_power 2.py +874 -0
  117. package/src/runtime_power.py +18 -1
  118. package/src/script_registry 2.py +1559 -0
  119. package/src/scripts/check-context 2.py +272 -0
  120. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  121. package/src/scripts/deep-sleep/collect 2.py +928 -0
  122. package/src/scripts/deep-sleep/extract 2.py +330 -0
  123. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  124. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  125. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  126. package/src/scripts/nexo-agent-run 2.py +75 -0
  127. package/src/scripts/nexo-auto-update 2.py +6 -0
  128. package/src/scripts/nexo-backup 2.sh +25 -0
  129. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  130. package/src/scripts/nexo-catchup 2.py +300 -0
  131. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  132. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  133. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  134. package/src/scripts/nexo-cron-wrapper.sh +7 -0
  135. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  136. package/src/scripts/nexo-dashboard 2.sh +29 -0
  137. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  138. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  139. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  140. package/src/scripts/nexo-hook-record 2.py +42 -0
  141. package/src/scripts/nexo-immune 2.py +936 -0
  142. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  143. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  144. package/src/scripts/nexo-install 2.py +6 -0
  145. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  146. package/src/scripts/nexo-learning-validator 2.py +266 -0
  147. package/src/scripts/nexo-migrate 2.py +260 -0
  148. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  149. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  150. package/src/scripts/nexo-pre-commit 2.py +120 -0
  151. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  152. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  153. package/src/scripts/nexo-reflection 2.py +256 -0
  154. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  155. package/src/scripts/nexo-sleep 2.py +631 -0
  156. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  157. package/src/scripts/nexo-sync-clients 2.py +16 -0
  158. package/src/scripts/nexo-synthesis 2.py +475 -0
  159. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  160. package/src/scripts/nexo-update 2.sh +306 -0
  161. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  162. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  163. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  164. package/src/server 2.py +1296 -0
  165. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  166. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  167. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  168. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  169. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  170. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  171. package/src/skills/run-release-final-audit/script 2.py +259 -0
  172. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  173. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  174. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  175. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  176. package/src/skills_runtime 2.py +932 -0
  177. package/src/state_watchers_runtime 2.py +475 -0
  178. package/src/storage_router 2.py +32 -0
  179. package/src/system_catalog 2.py +786 -0
  180. package/src/tools_coordination 2.py +103 -0
  181. package/src/tools_credentials 2.py +68 -0
  182. package/src/tools_drive 2.py +487 -0
  183. package/src/tools_hot_context 2.py +163 -0
  184. package/src/tools_learnings 2.py +612 -0
  185. package/src/tools_menu 2.py +229 -0
  186. package/src/tools_reminders 2.py +88 -0
  187. package/src/tools_reminders_crud 2.py +363 -0
  188. package/src/tools_sessions 2.py +1054 -0
  189. package/src/tools_system_catalog 2.py +19 -0
  190. package/src/tools_task_history 2.py +57 -0
  191. package/src/tools_transcripts 2.py +98 -0
  192. package/src/transcript_utils 2.py +412 -0
  193. package/src/user_context 2.py +46 -0
  194. package/src/user_data_portability 2.py +328 -0
  195. package/src/user_state_model 2.py +170 -0
  196. package/templates/CLAUDE.md 2.template +108 -0
  197. package/templates/CODEX.AGENTS.md 2.template +66 -0
  198. package/templates/launchagents/README 2.md +132 -0
  199. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  200. package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
  201. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  202. package/templates/launchagents/com.nexo.catchup.plist +1 -1
  203. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  204. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  205. package/templates/launchagents/com.nexo.dashboard.plist +1 -1
  206. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  207. package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
  208. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  209. package/templates/launchagents/com.nexo.evolution.plist +1 -1
  210. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  211. package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
  212. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  213. package/templates/launchagents/com.nexo.immune.plist +1 -1
  214. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  215. package/templates/launchagents/com.nexo.postmortem.plist +1 -1
  216. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  217. package/templates/launchagents/com.nexo.self-audit.plist +1 -1
  218. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  219. package/templates/launchagents/com.nexo.synthesis.plist +1 -1
  220. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  221. package/templates/launchagents/com.nexo.watchdog.plist +1 -1
  222. package/templates/nexo_helper 2.py +301 -0
  223. package/templates/openclaw 2.json +13 -0
  224. package/templates/plugin-template 2.py +40 -0
  225. package/templates/script-template 2.py +59 -0
  226. package/templates/script-template 2.sh +13 -0
  227. package/templates/script-template.py +5 -4
  228. package/templates/skill-script-template 2.py +48 -0
  229. package/templates/skill-script-template.py +2 -1
  230. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ """Durable queue for public-core ports discovered outside the public runner.
4
+
5
+ Managed flows such as self-audit may apply a local/core fix inline. When that
6
+ fix belongs in the public repository as well, we persist a normalized queue
7
+ entry in ``evolution_log`` so the weekly public contribution cycle can port it
8
+ later instead of losing the improvement inside one machine.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import sqlite3
14
+ from pathlib import Path
15
+
16
+
17
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
18
+ QUEUE_CLASSIFICATION = "public_port_queue"
19
+ QUEUE_STATUS_PENDING = "pending_public_port"
20
+ PUBLIC_ALLOWED_PREFIXES = (
21
+ "src/",
22
+ "bin/",
23
+ "tests/",
24
+ "templates/",
25
+ "hooks/",
26
+ "migrations/",
27
+ ".claude-plugin/",
28
+ )
29
+
30
+
31
+ def resolve_repo_root(nexo_code: str | os.PathLike[str] | None = None) -> Path | None:
32
+ raw = Path(
33
+ nexo_code
34
+ or os.environ.get("NEXO_CODE")
35
+ or str(NEXO_HOME)
36
+ ).expanduser()
37
+ candidates = []
38
+ if raw.name == "src":
39
+ candidates.append(raw.parent)
40
+ candidates.append(raw)
41
+ for candidate in candidates:
42
+ if (candidate / "package.json").exists():
43
+ return candidate.resolve()
44
+ return None
45
+
46
+
47
+ def normalize_public_path(
48
+ filepath: str,
49
+ *,
50
+ repo_root: Path | None = None,
51
+ ) -> str:
52
+ text = str(filepath or "").strip()
53
+ if not text:
54
+ return ""
55
+
56
+ normalized_raw = text.replace("\\", "/").lstrip("./")
57
+ if any(
58
+ normalized_raw == prefix.rstrip("/")
59
+ or normalized_raw.startswith(prefix)
60
+ for prefix in PUBLIC_ALLOWED_PREFIXES
61
+ ):
62
+ return normalized_raw
63
+
64
+ repo_root = repo_root or resolve_repo_root()
65
+ if not repo_root:
66
+ return ""
67
+
68
+ candidate = Path(text).expanduser()
69
+ if not candidate.is_absolute():
70
+ candidate = repo_root / candidate
71
+ try:
72
+ rel = candidate.resolve().relative_to(repo_root.resolve()).as_posix()
73
+ except Exception:
74
+ for prefix in PUBLIC_ALLOWED_PREFIXES:
75
+ marker = normalized_raw.find(prefix)
76
+ if marker >= 0:
77
+ return normalized_raw[marker:]
78
+ return ""
79
+ if any(rel == prefix.rstrip("/") or rel.startswith(prefix) for prefix in PUBLIC_ALLOWED_PREFIXES):
80
+ return rel
81
+ return ""
82
+
83
+
84
+ def is_public_core_path(filepath: str, *, repo_root: Path | None = None) -> bool:
85
+ return bool(normalize_public_path(filepath, repo_root=repo_root))
86
+
87
+
88
+ def queue_public_port_candidate(
89
+ conn: sqlite3.Connection,
90
+ *,
91
+ title: str,
92
+ reasoning: str,
93
+ files_changed: list[str],
94
+ source: str,
95
+ metadata: dict | None = None,
96
+ ) -> dict:
97
+ row = conn.execute(
98
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='evolution_log'"
99
+ ).fetchone()
100
+ if not row:
101
+ return {"ok": False, "reason": "evolution_log_missing"}
102
+
103
+ repo_root = resolve_repo_root()
104
+ normalized_files: list[str] = []
105
+ seen: set[str] = set()
106
+ for filepath in files_changed:
107
+ rel = normalize_public_path(filepath, repo_root=repo_root)
108
+ if rel and rel not in seen:
109
+ normalized_files.append(rel)
110
+ seen.add(rel)
111
+ if not normalized_files:
112
+ return {"ok": False, "reason": "no_public_files"}
113
+
114
+ proposal = str(title or "").strip()[:300] or "Managed core autofix queued for public port"
115
+ clean_reasoning = str(reasoning or "").strip()[:4000] or "Queued for public-core port."
116
+ payload = dict(metadata or {})
117
+ payload.setdefault("source", source)
118
+ payload["files"] = normalized_files
119
+
120
+ existing = conn.execute(
121
+ """SELECT id, status
122
+ FROM evolution_log
123
+ WHERE classification = ?
124
+ AND proposal = ?
125
+ AND files_changed = ?
126
+ AND status IN (?, 'draft_pr_created', 'skipped_duplicate_existing_pr')
127
+ ORDER BY id DESC
128
+ LIMIT 1""",
129
+ (
130
+ QUEUE_CLASSIFICATION,
131
+ proposal,
132
+ json.dumps(normalized_files),
133
+ QUEUE_STATUS_PENDING,
134
+ ),
135
+ ).fetchone()
136
+ if existing:
137
+ return {
138
+ "ok": True,
139
+ "queued": False,
140
+ "log_id": int(existing["id"]),
141
+ "status": str(existing["status"] or ""),
142
+ "files_changed": normalized_files,
143
+ }
144
+
145
+ cur = conn.execute(
146
+ """INSERT INTO evolution_log (
147
+ cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result
148
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
149
+ (
150
+ 0,
151
+ "public_core",
152
+ proposal,
153
+ QUEUE_CLASSIFICATION,
154
+ clean_reasoning,
155
+ QUEUE_STATUS_PENDING,
156
+ json.dumps(normalized_files),
157
+ json.dumps(payload, ensure_ascii=False),
158
+ ),
159
+ )
160
+ return {
161
+ "ok": True,
162
+ "queued": True,
163
+ "log_id": int(cur.lastrowid),
164
+ "status": QUEUE_STATUS_PENDING,
165
+ "files_changed": normalized_files,
166
+ }
167
+
168
+
169
+ def list_pending_public_port_candidates(
170
+ conn: sqlite3.Connection,
171
+ *,
172
+ limit: int = 3,
173
+ ) -> list[dict]:
174
+ rows = conn.execute(
175
+ """SELECT id, created_at, proposal, reasoning, status, files_changed, test_result
176
+ FROM evolution_log
177
+ WHERE classification = ?
178
+ AND status = ?
179
+ ORDER BY created_at ASC, id ASC
180
+ LIMIT ?""",
181
+ (QUEUE_CLASSIFICATION, QUEUE_STATUS_PENDING, max(1, int(limit))),
182
+ ).fetchall()
183
+ results: list[dict] = []
184
+ for row in rows:
185
+ metadata = {}
186
+ raw_payload = str(row["test_result"] or "").strip()
187
+ if raw_payload:
188
+ try:
189
+ parsed = json.loads(raw_payload)
190
+ if isinstance(parsed, dict):
191
+ metadata = parsed
192
+ except Exception:
193
+ metadata = {"raw": raw_payload}
194
+ files_changed = []
195
+ raw_files = str(row["files_changed"] or "").strip()
196
+ if raw_files:
197
+ try:
198
+ parsed_files = json.loads(raw_files)
199
+ if isinstance(parsed_files, list):
200
+ files_changed = [str(item).strip() for item in parsed_files if str(item).strip()]
201
+ except Exception:
202
+ pass
203
+ results.append(
204
+ {
205
+ "id": int(row["id"]),
206
+ "created_at": str(row["created_at"] or ""),
207
+ "title": str(row["proposal"] or ""),
208
+ "reasoning": str(row["reasoning"] or ""),
209
+ "status": str(row["status"] or ""),
210
+ "files_changed": files_changed,
211
+ "metadata": metadata,
212
+ }
213
+ )
214
+ return results
215
+
216
+
217
+ def update_public_port_candidate(
218
+ conn: sqlite3.Connection,
219
+ log_id: int,
220
+ *,
221
+ status: str,
222
+ metadata_patch: dict | None = None,
223
+ ) -> None:
224
+ row = conn.execute(
225
+ "SELECT test_result FROM evolution_log WHERE id = ? LIMIT 1",
226
+ (int(log_id),),
227
+ ).fetchone()
228
+ payload: dict = {}
229
+ if row and str(row["test_result"] or "").strip():
230
+ try:
231
+ parsed = json.loads(str(row["test_result"]))
232
+ if isinstance(parsed, dict):
233
+ payload = parsed
234
+ except Exception:
235
+ payload = {"raw": str(row["test_result"])}
236
+ if metadata_patch:
237
+ payload.update(metadata_patch)
238
+ conn.execute(
239
+ "UPDATE evolution_log SET status = ?, test_result = ? WHERE id = ?",
240
+ (status, json.dumps(payload, ensure_ascii=False), int(log_id)),
241
+ )
@@ -0,0 +1,14 @@
1
+ # NEXO Brain — runtime dependencies
2
+ # Core (required)
3
+ fastmcp>=2.9.0
4
+ numpy
5
+ tomli; python_version < "3.11"
6
+
7
+ # Embedding model (optional but recommended for cognitive features)
8
+ fastembed
9
+
10
+ # Dashboard (optional, only needed for `python -m dashboard.app`)
11
+ fastapi
12
+ uvicorn
13
+ pydantic
14
+ jinja2
@@ -2,6 +2,7 @@
2
2
  # Core (required)
3
3
  fastmcp>=2.9.0
4
4
  numpy
5
+ tomli; python_version < "3.11"
5
6
 
6
7
  # Embedding model (optional but recommended for cognitive features)
7
8
  fastembed
@@ -11,4 +12,3 @@ fastapi
11
12
  uvicorn
12
13
  pydantic
13
14
  jinja2
14
- tomli; python_version < "3.11"
@@ -0,0 +1,373 @@
1
+ """Retroactive application of new learnings to past decisions.
2
+
3
+ Closes Fase 2 item 3 of NEXO-AUDIT-2026-04-11. The other six items in the
4
+ phase wired existing infrastructure together, but this one was a true
5
+ green-field gap: grep for "retroactive" in src/ returned zero matches
6
+ before this module existed.
7
+
8
+ The idea is simple. Whenever a new learning lands with a `prevention`
9
+ rule, scan recent decisions and find the ones that would have been
10
+ decided differently under the new rule. Surface each match as a
11
+ deterministic `NF-RETRO-L<learning_id>-D<decision_id>` followup so the
12
+ operator can revisit the call without the system silently mutating any
13
+ historical record.
14
+
15
+ Why no new schema:
16
+ Followups already have idempotent INSERT OR REPLACE semantics on the
17
+ primary id. Using a deterministic id per (learning, decision) pair
18
+ means re-running the helper is a no-op. There is no need for a
19
+ `retroactive_learning_matches` table; the followups table is the
20
+ single source of truth and the existing dashboards already render it.
21
+
22
+ Matching strategy:
23
+ Two cheap signals combined into a single score in [0.0, 1.0]:
24
+ 1. applies_to overlap: if the learning lists files / areas / domains
25
+ in `applies_to`, and the decision's `domain` (or words from it)
26
+ matches any of those tokens, applies_to_score = 1.0 else 0.0.
27
+ 2. keyword overlap: significant tokens (>= 4 chars, not stopwords)
28
+ from the learning's title + content + prevention, intersected
29
+ with significant tokens from the decision's
30
+ decision + based_on + alternatives + context_ref. Score is
31
+ intersection_size / max(1, learning_token_count) clipped to 1.0.
32
+ Guardrail: if a learning defines `applies_to` and that anchor scores
33
+ below 0.3, auto-dismiss the match even if keyword overlap is high.
34
+ This blocks keyword-only false positives outside the learning's
35
+ actual blast radius.
36
+ Default match threshold: 0.4. Default cap: 5 matches per learning.
37
+
38
+ Anti-spam guards:
39
+ - Skip if the learning has no `prevention` (just narrative learnings
40
+ do not generate retroactive followups — they are not enforceable).
41
+ - Skip if the learning's status is not 'active'.
42
+ - Hard cap of `max_matches` followups per call (default 5).
43
+ - Per (learning, decision) idempotency via deterministic id.
44
+ - Lookback window default 14 days; configurable.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ from datetime import datetime
51
+ from typing import Any
52
+
53
+ # A small stopword list keeps the keyword matcher from picking up filler.
54
+ _STOPWORDS = frozenset({
55
+ "para", "este", "esta", "esto", "como", "cuando", "donde", "porque",
56
+ "pero", "tambien", "siempre", "nunca", "antes", "despues", "sobre",
57
+ "entre", "hacia", "desde", "hasta", "with", "from", "this", "that",
58
+ "have", "been", "will", "would", "should", "could", "after", "before",
59
+ "while", "when", "where", "what", "which", "their", "there", "these",
60
+ "those", "into", "than", "then", "very", "more", "most", "less",
61
+ "make", "made", "take", "took", "give", "given", "para", "como",
62
+ "cosa", "todo", "toda", "todos", "todas", "muy",
63
+ })
64
+
65
+ _TOKEN_RE = re.compile(r"[a-zA-ZáéíóúñÁÉÍÓÚÑ_][a-zA-Z0-9áéíóúñÁÉÍÓÚÑ_]{3,}")
66
+
67
+
68
+ def _significant_tokens(text: str) -> set[str]:
69
+ """Extract significant tokens (>=4 chars, not stopwords, lowercased)."""
70
+ if not text:
71
+ return set()
72
+ tokens = _TOKEN_RE.findall(text)
73
+ return {t.lower() for t in tokens if t.lower() not in _STOPWORDS and len(t) >= 4}
74
+
75
+
76
+ def _split_applies_to(value: str) -> set[str]:
77
+ """Split a learning's applies_to into normalized tokens."""
78
+ if not value:
79
+ return set()
80
+ pieces: set[str] = set()
81
+ for chunk in re.split(r"[,;\s]+", value):
82
+ chunk = chunk.strip().lower()
83
+ if chunk:
84
+ pieces.add(chunk)
85
+ # Also keep the basename so 'src/db/_core.py' matches a domain like '_core'.
86
+ base = chunk.rsplit("/", 1)[-1]
87
+ if base and base != chunk:
88
+ pieces.add(base)
89
+ return pieces
90
+
91
+
92
+ def _decision_text_blob(row: dict) -> str:
93
+ """Concatenate the searchable text fields of a decision row."""
94
+ parts = [
95
+ row.get("decision", "") or "",
96
+ row.get("based_on", "") or "",
97
+ row.get("alternatives", "") or "",
98
+ row.get("context_ref", "") or "",
99
+ row.get("domain", "") or "",
100
+ ]
101
+ return " ".join(parts)
102
+
103
+
104
+ def _score_match(
105
+ *,
106
+ learning_keywords: set[str],
107
+ learning_applies_to: set[str],
108
+ decision_row: dict,
109
+ ) -> tuple[float, dict]:
110
+ """Score how strongly a learning applies retroactively to a decision.
111
+
112
+ Returns (score in [0.0, 1.0], breakdown dict for transparency).
113
+ """
114
+ decision_blob = _decision_text_blob(decision_row)
115
+ decision_tokens = _significant_tokens(decision_blob)
116
+
117
+ if learning_applies_to:
118
+ domain_tokens = _significant_tokens(decision_row.get("domain", "") or "")
119
+ applies_to_hits = learning_applies_to & (domain_tokens | decision_tokens)
120
+ applies_to_score = 1.0 if applies_to_hits else 0.0
121
+ else:
122
+ applies_to_hits = set()
123
+ applies_to_score = 0.0
124
+
125
+ if learning_keywords:
126
+ keyword_hits = learning_keywords & decision_tokens
127
+ # Three significant overlapping tokens is a strong signal on its
128
+ # own — that is the threshold a human reviewer needs to suspect a
129
+ # rule violation. We score linearly up to that and clip.
130
+ keyword_score = min(1.0, len(keyword_hits) / 3.0)
131
+ else:
132
+ keyword_hits = set()
133
+ keyword_score = 0.0
134
+
135
+ gated_by_applies_to = bool(learning_applies_to and applies_to_score < 0.3)
136
+ # When a learning explicitly scopes its blast radius via applies_to,
137
+ # keyword overlap alone is too noisy to justify a retroactive review.
138
+ score = applies_to_score if gated_by_applies_to else max(applies_to_score, keyword_score)
139
+ breakdown = {
140
+ "applies_to_score": round(applies_to_score, 3),
141
+ "applies_to_hits": sorted(applies_to_hits),
142
+ "keyword_score": round(keyword_score, 3),
143
+ "keyword_hits": sorted(keyword_hits),
144
+ "gated_by_applies_to": gated_by_applies_to,
145
+ }
146
+ return round(score, 3), breakdown
147
+
148
+
149
+ def _format_followup_description(
150
+ *,
151
+ learning: dict,
152
+ decision: dict,
153
+ score: float,
154
+ breakdown: dict,
155
+ ) -> str:
156
+ learning_id = learning.get("id")
157
+ decision_id = decision.get("id")
158
+ title = (learning.get("title") or "").strip()
159
+ prevention = (learning.get("prevention") or "").strip()
160
+ domain = (decision.get("domain") or "").strip()
161
+ decision_text = (decision.get("decision") or "").strip()
162
+ created = (decision.get("created_at") or "").strip()
163
+
164
+ lines = [
165
+ f"Retroactive review: learning #{learning_id} may apply to decision #{decision_id}.",
166
+ f"Score: {score:.2f} (applies_to={breakdown.get('applies_to_score', 0)}, "
167
+ f"keyword={breakdown.get('keyword_score', 0)})",
168
+ "",
169
+ f"New learning: {title}",
170
+ f"Prevention rule: {prevention}",
171
+ "",
172
+ f"Past decision (#{decision_id}, {domain}, {created}):",
173
+ f" {decision_text[:280]}",
174
+ "",
175
+ "Action: revisit this decision under the new rule. Update the "
176
+ "decision row, capture a corrective learning, or close this "
177
+ "followup as 'still valid' if the rule does not actually conflict.",
178
+ ]
179
+ return "\n".join(lines)
180
+
181
+
182
+ def apply_learning_retroactively(
183
+ learning_id: int,
184
+ *,
185
+ lookback_days: int = 14,
186
+ max_matches: int = 5,
187
+ min_score: float = 0.4,
188
+ dry_run: bool = False,
189
+ ) -> dict:
190
+ """Scan recent decisions for ones a new learning would re-evaluate.
191
+
192
+ Args:
193
+ learning_id: The learning row id to apply.
194
+ lookback_days: How many days back to scan decisions (default 14).
195
+ max_matches: Hard cap of followups created per call (default 5).
196
+ min_score: Score threshold in [0.0, 1.0] for a match (default 0.4).
197
+ dry_run: If True, scores matches but does not create followups.
198
+
199
+ Returns:
200
+ {
201
+ "ok": bool,
202
+ "learning_id": int,
203
+ "scanned": int, # decisions inspected
204
+ "matched": int, # decisions scored at or above threshold
205
+ "followups_created": int, # actual followup INSERT OR REPLACE rows
206
+ "skipped_reason": str|None,
207
+ "matches": [
208
+ {"decision_id", "score", "breakdown", "followup_id"|None}, ...
209
+ ],
210
+ }
211
+
212
+ Best-effort: never raises. A failing decision row is logged via the
213
+ breakdown but does not abort the loop. The full payload is returned so
214
+ callers (handle_learning_add, MCP tool, tests) can react.
215
+ """
216
+ from db import get_db
217
+
218
+ base_result: dict[str, Any] = {
219
+ "ok": True,
220
+ "learning_id": int(learning_id),
221
+ "scanned": 0,
222
+ "matched": 0,
223
+ "followups_created": 0,
224
+ "skipped_reason": None,
225
+ "matches": [],
226
+ }
227
+
228
+ try:
229
+ conn = get_db()
230
+ except Exception as e:
231
+ base_result["ok"] = False
232
+ base_result["skipped_reason"] = f"cannot open db: {e}"
233
+ return base_result
234
+
235
+ try:
236
+ learning_row = conn.execute(
237
+ "SELECT id, category, title, content, prevention, applies_to, status, priority "
238
+ "FROM learnings WHERE id = ?",
239
+ (int(learning_id),),
240
+ ).fetchone()
241
+ except Exception as e:
242
+ base_result["ok"] = False
243
+ base_result["skipped_reason"] = f"learnings query failed: {e}"
244
+ return base_result
245
+
246
+ if not learning_row:
247
+ base_result["skipped_reason"] = "learning not found"
248
+ return base_result
249
+
250
+ learning = dict(learning_row)
251
+ if learning.get("status") and learning["status"] != "active":
252
+ base_result["skipped_reason"] = f"learning status is {learning['status']}, not active"
253
+ return base_result
254
+ prevention = (learning.get("prevention") or "").strip()
255
+ if not prevention:
256
+ base_result["skipped_reason"] = "learning has no prevention rule — nothing enforceable to apply"
257
+ return base_result
258
+
259
+ learning_keywords = _significant_tokens(
260
+ " ".join([
261
+ learning.get("title") or "",
262
+ learning.get("content") or "",
263
+ prevention,
264
+ ])
265
+ )
266
+ learning_applies_to = _split_applies_to(learning.get("applies_to") or "")
267
+
268
+ if not learning_keywords and not learning_applies_to:
269
+ base_result["skipped_reason"] = "learning has no usable keywords or applies_to anchors"
270
+ return base_result
271
+
272
+ try:
273
+ rows = conn.execute(
274
+ "SELECT id, session_id, created_at, domain, decision, alternatives, "
275
+ "based_on, confidence, context_ref, outcome, status "
276
+ "FROM decisions "
277
+ "WHERE created_at >= datetime('now', ?) "
278
+ "ORDER BY created_at DESC LIMIT 200",
279
+ (f"-{max(1, int(lookback_days))} days",),
280
+ ).fetchall()
281
+ except Exception as e:
282
+ base_result["ok"] = False
283
+ base_result["skipped_reason"] = f"decisions query failed: {e}"
284
+ return base_result
285
+
286
+ matches: list[dict] = []
287
+ for row in rows:
288
+ try:
289
+ decision = dict(row)
290
+ except Exception:
291
+ continue
292
+ base_result["scanned"] += 1
293
+ score, breakdown = _score_match(
294
+ learning_keywords=learning_keywords,
295
+ learning_applies_to=learning_applies_to,
296
+ decision_row=decision,
297
+ )
298
+ if score >= float(min_score):
299
+ matches.append({
300
+ "decision": decision,
301
+ "score": score,
302
+ "breakdown": breakdown,
303
+ })
304
+
305
+ matches.sort(key=lambda m: m["score"], reverse=True)
306
+ capped = matches[: max(0, int(max_matches))]
307
+ base_result["matched"] = len(matches)
308
+
309
+ if dry_run:
310
+ base_result["matches"] = [
311
+ {
312
+ "decision_id": int(m["decision"]["id"]),
313
+ "score": m["score"],
314
+ "breakdown": m["breakdown"],
315
+ "followup_id": None,
316
+ }
317
+ for m in capped
318
+ ]
319
+ return base_result
320
+
321
+ created = 0
322
+ now_epoch = datetime.now().timestamp()
323
+ summary_matches = []
324
+ for m in capped:
325
+ decision = m["decision"]
326
+ followup_id = f"NF-RETRO-L{learning['id']}-D{decision['id']}"
327
+ description = _format_followup_description(
328
+ learning=learning,
329
+ decision=decision,
330
+ score=m["score"],
331
+ breakdown=m["breakdown"],
332
+ )
333
+ verification = (
334
+ f"SELECT id, domain, decision, based_on, status FROM decisions WHERE id = {int(decision['id'])}"
335
+ )
336
+ try:
337
+ conn.execute(
338
+ "INSERT OR REPLACE INTO followups (id, description, date, status, "
339
+ "verification, created_at, updated_at, priority) "
340
+ "VALUES (?, ?, NULL, 'PENDING', ?, ?, ?, ?)",
341
+ (
342
+ followup_id,
343
+ description,
344
+ verification,
345
+ now_epoch,
346
+ now_epoch,
347
+ learning.get("priority") or "medium",
348
+ ),
349
+ )
350
+ created += 1
351
+ summary_matches.append({
352
+ "decision_id": int(decision["id"]),
353
+ "score": m["score"],
354
+ "breakdown": m["breakdown"],
355
+ "followup_id": followup_id,
356
+ })
357
+ except Exception as e:
358
+ summary_matches.append({
359
+ "decision_id": int(decision.get("id", 0)),
360
+ "score": m["score"],
361
+ "breakdown": m["breakdown"],
362
+ "followup_id": None,
363
+ "error": str(e),
364
+ })
365
+
366
+ try:
367
+ conn.commit()
368
+ except Exception:
369
+ pass
370
+
371
+ base_result["followups_created"] = created
372
+ base_result["matches"] = summary_matches
373
+ return base_result
File without changes