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,241 +0,0 @@
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
- )
@@ -1,14 +0,0 @@
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
@@ -1,373 +0,0 @@
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