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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/nexo-brain.js +52 -1
- package/package.json +1 -1
- package/src/crons/sync.py +18 -4
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_entities.py +1 -1
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/agents.py +10 -3
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/schedule.py +2 -1
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/requirements.txt +1 -1
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/runtime_power.py +18 -1
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-cron-wrapper.sh +7 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup.plist +1 -1
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.dashboard.plist +1 -1
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.evolution.plist +1 -1
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.immune.plist +1 -1
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.postmortem.plist +1 -1
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.self-audit.plist +1 -1
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.synthesis.plist +1 -1
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/launchagents/com.nexo.watchdog.plist +1 -1
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/script-template.py +5 -4
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-script-template.py +2 -1
- 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
|
package/src/requirements.txt
CHANGED
|
@@ -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
|