nexo-brain 5.3.26 → 5.3.27

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