nexo-brain 2.6.21 → 3.0.0
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/README.md +72 -20
- package/hooks/hooks.json +79 -0
- package/package.json +1 -1
- package/src/agent_runner.py +296 -8
- package/src/cli.py +209 -4
- package/src/client_preferences.py +115 -0
- package/src/client_sync.py +202 -2
- package/src/cognitive/__init__.py +1 -1
- package/src/cognitive/_search.py +39 -19
- package/src/dashboard/app.py +264 -0
- package/src/dashboard/templates/base.html +4 -0
- package/src/dashboard/templates/dashboard.html +59 -1
- package/src/dashboard/templates/protocol.html +199 -0
- package/src/db/__init__.py +23 -1
- package/src/db/_learnings.py +31 -4
- package/src/db/_personal_scripts.py +12 -0
- package/src/db/_protocol.py +303 -0
- package/src/db/_schema.py +248 -0
- package/src/db/_watchers.py +173 -0
- package/src/db/_workflow.py +952 -0
- package/src/doctor/providers/runtime.py +1095 -3
- package/src/evolution_cycle.py +62 -0
- package/src/hook_guardrails.py +308 -0
- package/src/hooks/protocol-guardrail.sh +10 -0
- package/src/nexo_sdk.py +103 -0
- package/src/plugins/cognitive_memory.py +18 -0
- package/src/plugins/cortex.py +55 -35
- package/src/plugins/guard.py +132 -16
- package/src/plugins/protocol.py +911 -0
- package/src/plugins/schedule.py +40 -6
- package/src/plugins/simple_api.py +103 -0
- package/src/plugins/skills.py +67 -0
- package/src/plugins/state_watchers.py +79 -0
- package/src/plugins/workflow.py +588 -0
- package/src/public_contribution.py +86 -12
- package/src/script_registry.py +142 -0
- package/src/scripts/deep-sleep/apply_findings.py +482 -2
- package/src/scripts/deep-sleep/collect.py +49 -4
- package/src/scripts/nexo-agent-run.py +2 -0
- package/src/scripts/nexo-daily-self-audit.py +843 -5
- package/src/scripts/nexo-evolution-run.py +343 -1
- package/src/server.py +92 -6
- package/src/skills_runtime.py +151 -0
- package/src/state_watchers_runtime.py +334 -0
- package/src/tools_learnings.py +345 -7
- package/src/tools_sessions.py +183 -0
- package/templates/CLAUDE.md.template +9 -1
- package/templates/CODEX.AGENTS.md.template +10 -2
package/src/tools_learnings.py
CHANGED
|
@@ -1,11 +1,267 @@
|
|
|
1
1
|
"""Learnings CRUD tools: add, search, update, delete, list."""
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
3
6
|
from db import (create_learning, update_learning, delete_learning, search_learnings,
|
|
4
|
-
list_learnings, find_similar_learnings, get_db, now_epoch)
|
|
7
|
+
list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning)
|
|
8
|
+
|
|
9
|
+
NEGATION_PATTERNS = (
|
|
10
|
+
"do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
|
|
11
|
+
"disable", "disabled", "remove", "ban", "bypass",
|
|
12
|
+
)
|
|
13
|
+
CONTRADICTION_PAIRS = (
|
|
14
|
+
("enable", "disable"),
|
|
15
|
+
("use", "avoid"),
|
|
16
|
+
("add", "remove"),
|
|
17
|
+
("allow", "forbid"),
|
|
18
|
+
("always", "never"),
|
|
19
|
+
("before", "after"),
|
|
20
|
+
("require", "skip"),
|
|
21
|
+
("validate", "skip"),
|
|
22
|
+
("validate", "bypass"),
|
|
23
|
+
("include", "exclude"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _split_applies_to(applies_to: str) -> list[str]:
|
|
28
|
+
return [item.strip() for item in str(applies_to or "").split(",") if item.strip()]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_applies_token(value: str) -> str:
|
|
32
|
+
return str(value or "").replace("\\", "/").rstrip("/").lower()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _applies_overlap(left: str, right: str) -> bool:
|
|
36
|
+
left_tokens = {_normalize_applies_token(item) for item in _split_applies_to(left)}
|
|
37
|
+
right_tokens = {_normalize_applies_token(item) for item in _split_applies_to(right)}
|
|
38
|
+
left_tokens.discard("")
|
|
39
|
+
right_tokens.discard("")
|
|
40
|
+
if not left_tokens or not right_tokens:
|
|
41
|
+
return False
|
|
42
|
+
if left_tokens & right_tokens:
|
|
43
|
+
return True
|
|
44
|
+
for left_token in left_tokens:
|
|
45
|
+
for right_token in right_tokens:
|
|
46
|
+
if "/" in left_token or "/" in right_token:
|
|
47
|
+
if left_token.startswith(f"{right_token}/") or right_token.startswith(f"{left_token}/"):
|
|
48
|
+
return True
|
|
49
|
+
if left_token.endswith(f"/{right_token}") or right_token.endswith(f"/{left_token}"):
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_text(text: str) -> str:
|
|
55
|
+
return re.sub(r"\s+", " ", str(text or "").strip().lower())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tokenize(text: str) -> list[str]:
|
|
59
|
+
return re.findall(r"[a-z0-9_-]+", _normalize_text(text))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _contains_negation(text: str) -> bool:
|
|
63
|
+
lowered = _normalize_text(text)
|
|
64
|
+
return any(token in lowered for token in NEGATION_PATTERNS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _negated_action_verbs(text: str) -> set[str]:
|
|
68
|
+
lowered = _normalize_text(text)
|
|
69
|
+
matches = set()
|
|
70
|
+
for pattern in (
|
|
71
|
+
r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)",
|
|
72
|
+
r"(?:do not|don't)\s+([a-z0-9_-]+)",
|
|
73
|
+
):
|
|
74
|
+
matches.update(re.findall(pattern, lowered))
|
|
75
|
+
return {match for match in matches if len(match) > 2}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _looks_contradictory(existing_text: str, new_text: str) -> bool:
|
|
79
|
+
existing_norm = _normalize_text(existing_text)
|
|
80
|
+
new_norm = _normalize_text(new_text)
|
|
81
|
+
if not existing_norm or not new_norm:
|
|
82
|
+
return False
|
|
83
|
+
existing_tokens = set(_tokenize(existing_norm))
|
|
84
|
+
new_tokens = set(_tokenize(new_norm))
|
|
85
|
+
if not (existing_tokens & new_tokens):
|
|
86
|
+
return False
|
|
87
|
+
existing_negated_verbs = _negated_action_verbs(existing_norm)
|
|
88
|
+
new_negated_verbs = _negated_action_verbs(new_norm)
|
|
89
|
+
if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
90
|
+
return True
|
|
91
|
+
if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
92
|
+
return True
|
|
93
|
+
if _contains_negation(existing_norm) != _contains_negation(new_norm):
|
|
94
|
+
return True
|
|
95
|
+
for positive, negative in CONTRADICTION_PAIRS:
|
|
96
|
+
existing_has_pair = positive in existing_norm or negative in existing_norm
|
|
97
|
+
new_has_pair = positive in new_norm or negative in new_norm
|
|
98
|
+
if existing_has_pair and new_has_pair:
|
|
99
|
+
if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _find_conflicting_active_learning(conn, *, category: str, title: str, content: str,
|
|
105
|
+
applies_to: str, exclude_id: int | None = None) -> dict | None:
|
|
106
|
+
if not applies_to:
|
|
107
|
+
return None
|
|
108
|
+
params = [category]
|
|
109
|
+
sql = (
|
|
110
|
+
"SELECT id, title, content, applies_to FROM learnings "
|
|
111
|
+
"WHERE category = ? AND status = 'active' AND COALESCE(applies_to, '') != ''"
|
|
112
|
+
)
|
|
113
|
+
if exclude_id is not None:
|
|
114
|
+
sql += " AND id != ?"
|
|
115
|
+
params.append(exclude_id)
|
|
116
|
+
rows = conn.execute(sql, tuple(params)).fetchall()
|
|
117
|
+
incoming_text = f"{title} {content}"
|
|
118
|
+
for row in rows:
|
|
119
|
+
if not _applies_overlap(row["applies_to"], applies_to):
|
|
120
|
+
continue
|
|
121
|
+
if _looks_contradictory(f"{row['title']} {row['content']}", incoming_text):
|
|
122
|
+
return dict(row)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_conflicting_active_learning(*, category: str, title: str, content: str,
|
|
127
|
+
applies_to: str, exclude_id: int | None = None) -> dict | None:
|
|
128
|
+
"""Public wrapper for canonical-rule enforcement on file-scoped learnings."""
|
|
129
|
+
conn = get_db()
|
|
130
|
+
return _find_conflicting_active_learning(
|
|
131
|
+
conn,
|
|
132
|
+
category=category.lower().strip(),
|
|
133
|
+
title=title,
|
|
134
|
+
content=content,
|
|
135
|
+
applies_to=applies_to,
|
|
136
|
+
exclude_id=exclude_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _priority_score(priority: str) -> float:
|
|
141
|
+
return {
|
|
142
|
+
"critical": 1.0,
|
|
143
|
+
"high": 0.85,
|
|
144
|
+
"medium": 0.65,
|
|
145
|
+
"low": 0.45,
|
|
146
|
+
}.get(str(priority or "medium").strip().lower(), 0.65)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_timestamp(value) -> float:
|
|
150
|
+
if isinstance(value, (int, float)):
|
|
151
|
+
return float(value)
|
|
152
|
+
text = str(value or "").strip()
|
|
153
|
+
if not text:
|
|
154
|
+
return 0.0
|
|
155
|
+
try:
|
|
156
|
+
return float(text)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
for fmt in (None, "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
160
|
+
try:
|
|
161
|
+
if fmt is None:
|
|
162
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
|
163
|
+
return datetime.strptime(text, fmt).timestamp()
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
return 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _recency_score(row: dict) -> float:
|
|
170
|
+
reference = _parse_timestamp(row.get("last_guard_hit_at")) or _parse_timestamp(row.get("updated_at")) or _parse_timestamp(row.get("created_at"))
|
|
171
|
+
if not reference:
|
|
172
|
+
return 0.35
|
|
173
|
+
age_days = max(0.0, (now_epoch() - reference) / 86400.0)
|
|
174
|
+
if age_days <= 7:
|
|
175
|
+
return 1.0
|
|
176
|
+
if age_days <= 30:
|
|
177
|
+
return 0.8
|
|
178
|
+
if age_days <= 90:
|
|
179
|
+
return 0.6
|
|
180
|
+
if age_days <= 180:
|
|
181
|
+
return 0.4
|
|
182
|
+
return 0.25
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _usefulness_score(row: dict) -> float:
|
|
186
|
+
hits = int(row.get("guard_hits") or 0)
|
|
187
|
+
if hits >= 5:
|
|
188
|
+
return 1.0
|
|
189
|
+
if hits >= 3:
|
|
190
|
+
return 0.85
|
|
191
|
+
if hits >= 1:
|
|
192
|
+
return 0.65
|
|
193
|
+
return 0.35 if str(row.get("status") or "active") == "active" else 0.15
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _source_richness_score(row: dict) -> float:
|
|
197
|
+
parts = 0.0
|
|
198
|
+
if str(row.get("reasoning") or "").strip():
|
|
199
|
+
parts += 0.25
|
|
200
|
+
if str(row.get("prevention") or "").strip():
|
|
201
|
+
parts += 0.25
|
|
202
|
+
if str(row.get("applies_to") or "").strip():
|
|
203
|
+
parts += 0.2
|
|
204
|
+
if row.get("review_due_at"):
|
|
205
|
+
parts += 0.15
|
|
206
|
+
if len(str(row.get("content") or "").strip()) >= 80:
|
|
207
|
+
parts += 0.15
|
|
208
|
+
return min(1.0, parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _contradiction_pressure_score(conn, row: dict) -> float:
|
|
212
|
+
if str(row.get("status") or "active") != "active":
|
|
213
|
+
return 0.7
|
|
214
|
+
conflicting = _find_conflicting_active_learning(
|
|
215
|
+
conn,
|
|
216
|
+
category=str(row.get("category") or ""),
|
|
217
|
+
title=str(row.get("title") or ""),
|
|
218
|
+
content=str(row.get("content") or ""),
|
|
219
|
+
applies_to=str(row.get("applies_to") or ""),
|
|
220
|
+
exclude_id=int(row.get("id") or 0),
|
|
221
|
+
)
|
|
222
|
+
return 0.0 if conflicting else 1.0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def score_learning_quality(row: dict, conn=None) -> dict:
|
|
226
|
+
"""Compute a 0-100 quality score for a learning using usefulness and conflict pressure."""
|
|
227
|
+
conn = conn or get_db()
|
|
228
|
+
confidence = min(1.0, (_priority_score(row.get("priority")) * 0.45) + (float(row.get("weight") or 0.5) * 0.55))
|
|
229
|
+
usefulness = _usefulness_score(row)
|
|
230
|
+
recency = _recency_score(row)
|
|
231
|
+
contradiction = _contradiction_pressure_score(conn, row)
|
|
232
|
+
source_richness = _source_richness_score(row)
|
|
233
|
+
status = str(row.get("status") or "active")
|
|
234
|
+
status_multiplier = 1.0 if status == "active" else 0.65 if status in {"pending_review", "review"} else 0.45
|
|
235
|
+
overall = (
|
|
236
|
+
confidence * 0.28
|
|
237
|
+
+ usefulness * 0.24
|
|
238
|
+
+ recency * 0.18
|
|
239
|
+
+ contradiction * 0.18
|
|
240
|
+
+ source_richness * 0.12
|
|
241
|
+
) * status_multiplier
|
|
242
|
+
score = max(0, min(100, round(overall * 100)))
|
|
243
|
+
if score >= 80:
|
|
244
|
+
label = "strong"
|
|
245
|
+
elif score >= 60:
|
|
246
|
+
label = "usable"
|
|
247
|
+
elif score >= 40:
|
|
248
|
+
label = "weak"
|
|
249
|
+
else:
|
|
250
|
+
label = "fragile"
|
|
251
|
+
return {
|
|
252
|
+
"score": score,
|
|
253
|
+
"label": label,
|
|
254
|
+
"confidence": round(confidence * 100),
|
|
255
|
+
"usefulness": round(usefulness * 100),
|
|
256
|
+
"recency_relevance": round(recency * 100),
|
|
257
|
+
"contradiction_pressure": round((1.0 - contradiction) * 100),
|
|
258
|
+
"source_richness": round(source_richness * 100),
|
|
259
|
+
}
|
|
260
|
+
|
|
5
261
|
|
|
6
262
|
def handle_learning_add(category: str, title: str, content: str, reasoning: str = '',
|
|
7
263
|
prevention: str = '', applies_to: str = '', review_days: int = 30,
|
|
8
|
-
priority: str = 'medium') -> str:
|
|
264
|
+
priority: str = 'medium', supersedes_id: int = 0) -> str:
|
|
9
265
|
"""Add a new learning entry to the specified category.
|
|
10
266
|
|
|
11
267
|
Args:
|
|
@@ -31,8 +287,21 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
|
|
|
31
287
|
).fetchone()
|
|
32
288
|
if existing:
|
|
33
289
|
return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
|
|
290
|
+
conflicting = _find_conflicting_active_learning(
|
|
291
|
+
conn,
|
|
292
|
+
category=category,
|
|
293
|
+
title=title,
|
|
294
|
+
content=content,
|
|
295
|
+
applies_to=applies_to,
|
|
296
|
+
)
|
|
297
|
+
if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
|
|
298
|
+
return (
|
|
299
|
+
f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
|
|
300
|
+
f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
|
|
301
|
+
f"Supersede or update the existing canonical rule instead of creating two active file rules."
|
|
302
|
+
)
|
|
34
303
|
result = create_learning(
|
|
35
|
-
category, title, content, reasoning=reasoning
|
|
304
|
+
category, title, content, reasoning=reasoning, supersedes_id=(int(supersedes_id) if supersedes_id else None)
|
|
36
305
|
)
|
|
37
306
|
if "error" in result:
|
|
38
307
|
return f"ERROR: {result['error']}"
|
|
@@ -102,11 +371,18 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
|
|
|
102
371
|
except Exception:
|
|
103
372
|
pass
|
|
104
373
|
|
|
374
|
+
if supersedes_id:
|
|
375
|
+
superseded = supersede_learning(int(supersedes_id), new_id, f"Superseded by learning #{new_id}.")
|
|
376
|
+
if "error" in superseded:
|
|
377
|
+
return f"ERROR: Learning #{new_id} created but supersede failed: {superseded['error']}"
|
|
378
|
+
|
|
105
379
|
meta = []
|
|
106
380
|
if prevention:
|
|
107
381
|
meta.append("with prevention")
|
|
108
382
|
if applies_to:
|
|
109
383
|
meta.append(f"applies_to={applies_to}")
|
|
384
|
+
if supersedes_id:
|
|
385
|
+
meta.append(f"supersedes={int(supersedes_id)}")
|
|
110
386
|
meta_str = f" ({', '.join(meta)})" if meta else ""
|
|
111
387
|
return f"Learning #{result['id']} added in {category}: {title}{meta_str}{repetition_msg}"
|
|
112
388
|
|
|
@@ -117,6 +393,7 @@ def handle_learning_search(query: str, category: str = '') -> str:
|
|
|
117
393
|
if not results:
|
|
118
394
|
return f"No results for '{query}'."
|
|
119
395
|
lines = [f"RESULTS ({len(results)}):"]
|
|
396
|
+
conn = get_db()
|
|
120
397
|
for r in results:
|
|
121
398
|
snippet = r["content"][:100] + "..." if len(r["content"]) > 100 else r["content"]
|
|
122
399
|
status = r.get("status", "active")
|
|
@@ -124,8 +401,9 @@ def handle_learning_search(query: str, category: str = '') -> str:
|
|
|
124
401
|
review_note = f" | review_due={review_due:.0f}" if isinstance(review_due, (int, float)) and review_due else ""
|
|
125
402
|
pri = r.get("priority", "medium") or "medium"
|
|
126
403
|
w = r.get("weight", 0.5) or 0.5
|
|
404
|
+
quality = score_learning_quality(r, conn)
|
|
127
405
|
pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
|
|
128
|
-
lines.append(f" #{r['id']} [{r['category']}] [{status}] {pri_icon}{pri} w={w:.2f} {r['title']}{review_note}")
|
|
406
|
+
lines.append(f" #{r['id']} [{r['category']}] [{status}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}{review_note}")
|
|
129
407
|
lines.append(f" {snippet}")
|
|
130
408
|
if r.get("prevention"):
|
|
131
409
|
lines.append(f" Prevention: {r['prevention'][:100]}")
|
|
@@ -143,8 +421,13 @@ def handle_learning_search(query: str, category: str = '') -> str:
|
|
|
143
421
|
|
|
144
422
|
def handle_learning_update(id: int, title: str = '', content: str = '', category: str = '',
|
|
145
423
|
reasoning: str = '', prevention: str = '', applies_to: str = '',
|
|
146
|
-
status: str = '', review_days: int = 0, priority: str = ''
|
|
424
|
+
status: str = '', review_days: int = 0, priority: str = '',
|
|
425
|
+
supersedes_id: int = 0) -> str:
|
|
147
426
|
"""Update an existing learning, including review metadata and priority."""
|
|
427
|
+
conn = get_db()
|
|
428
|
+
current = conn.execute("SELECT * FROM learnings WHERE id = ?", (id,)).fetchone()
|
|
429
|
+
if not current:
|
|
430
|
+
return f"ERROR: Learning #{id} not found."
|
|
148
431
|
kwargs = {}
|
|
149
432
|
if title:
|
|
150
433
|
kwargs["title"] = title
|
|
@@ -164,6 +447,26 @@ def handle_learning_update(id: int, title: str = '', content: str = '', category
|
|
|
164
447
|
kwargs["review_days"] = review_days
|
|
165
448
|
if not kwargs:
|
|
166
449
|
return "ERROR: Nothing to update. Provide new fields."
|
|
450
|
+
effective_category = kwargs.get("category", current["category"])
|
|
451
|
+
effective_title = kwargs.get("title", current["title"])
|
|
452
|
+
effective_content = kwargs.get("content", current["content"])
|
|
453
|
+
effective_applies_to = kwargs.get("applies_to", current["applies_to"])
|
|
454
|
+
effective_status = kwargs.get("status", current["status"])
|
|
455
|
+
if effective_status != "superseded":
|
|
456
|
+
conflicting = _find_conflicting_active_learning(
|
|
457
|
+
conn,
|
|
458
|
+
category=effective_category,
|
|
459
|
+
title=effective_title,
|
|
460
|
+
content=effective_content,
|
|
461
|
+
applies_to=effective_applies_to,
|
|
462
|
+
exclude_id=id,
|
|
463
|
+
)
|
|
464
|
+
if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
|
|
465
|
+
return (
|
|
466
|
+
f"ERROR: Update would conflict with active learning #{conflicting['id']} "
|
|
467
|
+
f"for applies_to={conflicting.get('applies_to', '')}. "
|
|
468
|
+
f"Supersede the old rule or merge into one canonical learning."
|
|
469
|
+
)
|
|
167
470
|
basic_kwargs = {k: v for k, v in kwargs.items() if k in {"title", "content", "category", "reasoning"}}
|
|
168
471
|
result = update_learning(id, **basic_kwargs)
|
|
169
472
|
if "error" in result:
|
|
@@ -187,6 +490,10 @@ def handle_learning_update(id: int, title: str = '', content: str = '', category
|
|
|
187
490
|
conn = get_db()
|
|
188
491
|
conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", values)
|
|
189
492
|
conn.commit()
|
|
493
|
+
if supersedes_id:
|
|
494
|
+
superseded = supersede_learning(int(supersedes_id), id, f"Superseded by learning #{id}.")
|
|
495
|
+
if "error" in superseded:
|
|
496
|
+
return f"ERROR: Learning #{id} updated but supersede failed: {superseded['error']}"
|
|
190
497
|
return f"Learning #{id} updated."
|
|
191
498
|
|
|
192
499
|
|
|
@@ -205,14 +512,16 @@ def handle_learning_list(category: str = '') -> str:
|
|
|
205
512
|
label = category if category else "ALL"
|
|
206
513
|
return f"LEARNINGS {label} (0): No entries."
|
|
207
514
|
|
|
515
|
+
conn = get_db()
|
|
208
516
|
if category:
|
|
209
517
|
label = category.upper()
|
|
210
518
|
lines = [f"LEARNINGS {label} ({len(results)}):"]
|
|
211
519
|
for r in results:
|
|
212
520
|
pri = r.get("priority", "medium") or "medium"
|
|
213
521
|
w = r.get("weight", 0.5) or 0.5
|
|
522
|
+
quality = score_learning_quality(r, conn)
|
|
214
523
|
pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
|
|
215
|
-
lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} {r['title']}")
|
|
524
|
+
lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}")
|
|
216
525
|
else:
|
|
217
526
|
lines = [f"LEARNINGS ALL ({len(results)}):"]
|
|
218
527
|
current_cat = None
|
|
@@ -222,7 +531,36 @@ def handle_learning_list(category: str = '') -> str:
|
|
|
222
531
|
lines.append(f"\n [{current_cat.upper()}]")
|
|
223
532
|
pri = r.get("priority", "medium") or "medium"
|
|
224
533
|
w = r.get("weight", 0.5) or 0.5
|
|
534
|
+
quality = score_learning_quality(r, conn)
|
|
225
535
|
pri_icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "⚪"}.get(pri, "🟡")
|
|
226
|
-
lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} {r['title']}")
|
|
536
|
+
lines.append(f" #{r['id']} [{r.get('status','active')}] {pri_icon}{pri} w={w:.2f} q={quality['score']} {r['title']}")
|
|
537
|
+
|
|
538
|
+
return "\n".join(lines)
|
|
227
539
|
|
|
540
|
+
|
|
541
|
+
def handle_learning_quality(id: int = 0, category: str = "", status: str = "active", limit: int = 20) -> str:
|
|
542
|
+
"""Inspect memory quality so fragile learnings can be tightened before they mislead guard/retrieval."""
|
|
543
|
+
results = list_learnings(category if category else None)
|
|
544
|
+
if id:
|
|
545
|
+
results = [row for row in results if int(row.get("id") or 0) == int(id)]
|
|
546
|
+
if status:
|
|
547
|
+
results = [row for row in results if str(row.get("status") or "").lower() == str(status).lower()]
|
|
548
|
+
results = results[: max(1, int(limit or 20))]
|
|
549
|
+
if not results:
|
|
550
|
+
return "LEARNING QUALITY (0): No matching learnings."
|
|
551
|
+
|
|
552
|
+
conn = get_db()
|
|
553
|
+
scored = []
|
|
554
|
+
for row in results:
|
|
555
|
+
quality = score_learning_quality(row, conn)
|
|
556
|
+
scored.append((row, quality))
|
|
557
|
+
avg_score = round(sum(item[1]["score"] for item in scored) / len(scored))
|
|
558
|
+
weak = [item for item in scored if item[1]["score"] < 60]
|
|
559
|
+
lines = [f"LEARNING QUALITY ({len(scored)}) avg={avg_score} weak={len(weak)}:"]
|
|
560
|
+
for row, quality in scored:
|
|
561
|
+
lines.append(
|
|
562
|
+
f" #{row['id']} q={quality['score']} [{quality['label']}] {row['title']} "
|
|
563
|
+
f"(conf={quality['confidence']} useful={quality['usefulness']} recency={quality['recency_relevance']} "
|
|
564
|
+
f"pressure={quality['contradiction_pressure']} richness={quality['source_richness']})"
|
|
565
|
+
)
|
|
228
566
|
return "\n".join(lines)
|
package/src/tools_sessions.py
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
"""Session management tools: startup, heartbeat, status."""
|
|
3
3
|
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
4
6
|
import time
|
|
5
7
|
import secrets
|
|
6
8
|
import threading
|
|
9
|
+
from datetime import datetime, UTC
|
|
10
|
+
from pathlib import Path
|
|
7
11
|
from db import (
|
|
8
12
|
register_session, update_session, complete_session,
|
|
9
13
|
get_active_sessions, clean_stale_sessions, search_sessions,
|
|
10
14
|
get_inbox, get_pending_questions, now_epoch,
|
|
11
15
|
SESSION_STALE_SECONDS, check_session_has_diary,
|
|
12
16
|
save_checkpoint, read_checkpoint, increment_compaction_count,
|
|
17
|
+
get_db,
|
|
13
18
|
)
|
|
14
19
|
|
|
15
20
|
# ── Session Keepalive ────────────────────────────────────────────────
|
|
@@ -19,6 +24,8 @@ from db import (
|
|
|
19
24
|
# Threads are daemon=True so they die when the MCP server process exits.
|
|
20
25
|
|
|
21
26
|
KEEPALIVE_INTERVAL = 600 # 10 min — well inside the 15-min TTL
|
|
27
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
28
|
+
SESSION_PORTABILITY_DIR = NEXO_HOME / "operations" / "session-portability"
|
|
22
29
|
|
|
23
30
|
_keepalive_threads: dict[str, threading.Event] = {} # sid → stop_event
|
|
24
31
|
|
|
@@ -64,6 +71,182 @@ def _format_age(epoch: float) -> str:
|
|
|
64
71
|
return f"{int(seconds / 3600)}h{int((seconds % 3600) / 60)}m"
|
|
65
72
|
|
|
66
73
|
|
|
74
|
+
def _resolve_session_row(conn, sid: str = ""):
|
|
75
|
+
if sid.strip():
|
|
76
|
+
return conn.execute("SELECT * FROM sessions WHERE sid = ?", (sid.strip(),)).fetchone()
|
|
77
|
+
return conn.execute(
|
|
78
|
+
"SELECT * FROM sessions ORDER BY last_update_epoch DESC LIMIT 1"
|
|
79
|
+
).fetchone()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _session_portability_bundle(sid: str = "") -> dict:
|
|
83
|
+
conn = get_db()
|
|
84
|
+
session_row = _resolve_session_row(conn, sid)
|
|
85
|
+
if not session_row:
|
|
86
|
+
return {"ok": False, "error": "session not found"}
|
|
87
|
+
|
|
88
|
+
session_id = str(session_row["sid"])
|
|
89
|
+
checkpoint = read_checkpoint(session_id) or {}
|
|
90
|
+
diary = conn.execute(
|
|
91
|
+
"""SELECT summary, decisions, pending, context_next, mental_state, domain, created_at
|
|
92
|
+
FROM session_diary
|
|
93
|
+
WHERE session_id = ?
|
|
94
|
+
ORDER BY created_at DESC
|
|
95
|
+
LIMIT 1""",
|
|
96
|
+
(session_id,),
|
|
97
|
+
).fetchone()
|
|
98
|
+
draft = conn.execute(
|
|
99
|
+
"""SELECT summary_draft, last_context_hint, updated_at
|
|
100
|
+
FROM session_diary_draft
|
|
101
|
+
WHERE sid = ?""",
|
|
102
|
+
(session_id,),
|
|
103
|
+
).fetchone()
|
|
104
|
+
protocol_tasks = [
|
|
105
|
+
dict(row) for row in conn.execute(
|
|
106
|
+
"""SELECT task_id, goal, task_type, area, status, opened_at
|
|
107
|
+
FROM protocol_tasks
|
|
108
|
+
WHERE session_id = ? AND status = 'open'
|
|
109
|
+
ORDER BY opened_at DESC
|
|
110
|
+
LIMIT 10""",
|
|
111
|
+
(session_id,),
|
|
112
|
+
).fetchall()
|
|
113
|
+
]
|
|
114
|
+
workflow_goals = [
|
|
115
|
+
dict(row) for row in conn.execute(
|
|
116
|
+
"""SELECT goal_id, title, status, priority, next_action, blocker_reason, updated_at
|
|
117
|
+
FROM workflow_goals
|
|
118
|
+
WHERE session_id = ? AND status IN ('active', 'blocked')
|
|
119
|
+
ORDER BY updated_at DESC
|
|
120
|
+
LIMIT 10""",
|
|
121
|
+
(session_id,),
|
|
122
|
+
).fetchall()
|
|
123
|
+
]
|
|
124
|
+
workflow_runs = [
|
|
125
|
+
dict(row) for row in conn.execute(
|
|
126
|
+
"""SELECT run_id, goal_id, goal, workflow_kind, status, priority, next_action, current_step_key, updated_at
|
|
127
|
+
FROM workflow_runs
|
|
128
|
+
WHERE session_id = ? AND status IN ('open', 'running', 'blocked', 'needs_approval')
|
|
129
|
+
ORDER BY updated_at DESC
|
|
130
|
+
LIMIT 10""",
|
|
131
|
+
(session_id,),
|
|
132
|
+
).fetchall()
|
|
133
|
+
]
|
|
134
|
+
return {
|
|
135
|
+
"ok": True,
|
|
136
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
137
|
+
"session": {
|
|
138
|
+
"sid": session_id,
|
|
139
|
+
"task": session_row["task"],
|
|
140
|
+
"client": session_row["session_client"],
|
|
141
|
+
"external_session_id": session_row["external_session_id"],
|
|
142
|
+
"started_epoch": session_row["started_epoch"],
|
|
143
|
+
"last_update_epoch": session_row["last_update_epoch"],
|
|
144
|
+
"local_time": session_row["local_time"],
|
|
145
|
+
},
|
|
146
|
+
"checkpoint": dict(checkpoint) if checkpoint else {},
|
|
147
|
+
"latest_diary": dict(diary) if diary else {},
|
|
148
|
+
"diary_draft": dict(draft) if draft else {},
|
|
149
|
+
"open_protocol_tasks": protocol_tasks,
|
|
150
|
+
"open_workflow_goals": workflow_goals,
|
|
151
|
+
"open_workflow_runs": workflow_runs,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def handle_session_portable_context(sid: str = "") -> str:
|
|
156
|
+
"""Build a portable handoff packet for another client/runtime."""
|
|
157
|
+
bundle = _session_portability_bundle(sid)
|
|
158
|
+
if not bundle.get("ok"):
|
|
159
|
+
return f"ERROR: {bundle.get('error', 'session not found')}"
|
|
160
|
+
|
|
161
|
+
session = bundle["session"]
|
|
162
|
+
checkpoint = bundle.get("checkpoint") or {}
|
|
163
|
+
diary = bundle.get("latest_diary") or {}
|
|
164
|
+
draft = bundle.get("diary_draft") or {}
|
|
165
|
+
lines = [
|
|
166
|
+
"SESSION PORTABILITY PACKET",
|
|
167
|
+
f"SID: {session['sid']}",
|
|
168
|
+
f"Task: {session['task'] or '(none)'}",
|
|
169
|
+
f"Client: {session['client'] or '(unknown)'}",
|
|
170
|
+
]
|
|
171
|
+
if session.get("external_session_id"):
|
|
172
|
+
lines.append(f"External session: {session['external_session_id']}")
|
|
173
|
+
if checkpoint:
|
|
174
|
+
lines.extend(
|
|
175
|
+
[
|
|
176
|
+
"",
|
|
177
|
+
"Checkpoint:",
|
|
178
|
+
f"- Goal: {checkpoint.get('current_goal') or checkpoint.get('task') or '(none)'}",
|
|
179
|
+
f"- Next: {checkpoint.get('next_step') or '(none)'}",
|
|
180
|
+
f"- Files: {checkpoint.get('active_files') or '[]'}",
|
|
181
|
+
]
|
|
182
|
+
)
|
|
183
|
+
if diary:
|
|
184
|
+
lines.extend(
|
|
185
|
+
[
|
|
186
|
+
"",
|
|
187
|
+
"Latest diary:",
|
|
188
|
+
f"- Summary: {diary.get('summary') or '(none)'}",
|
|
189
|
+
f"- Pending: {diary.get('pending') or '(none)'}",
|
|
190
|
+
f"- Context next: {diary.get('context_next') or '(none)'}",
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
elif draft:
|
|
194
|
+
lines.extend(
|
|
195
|
+
[
|
|
196
|
+
"",
|
|
197
|
+
"Diary draft:",
|
|
198
|
+
f"- Summary draft: {draft.get('summary_draft') or '(none)'}",
|
|
199
|
+
f"- Context hint: {draft.get('last_context_hint') or '(none)'}",
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
protocol_tasks = bundle.get("open_protocol_tasks") or []
|
|
204
|
+
if protocol_tasks:
|
|
205
|
+
lines.extend(["", "Open protocol tasks:"])
|
|
206
|
+
for item in protocol_tasks[:5]:
|
|
207
|
+
lines.append(f"- {item['task_id']}: {item['goal']} [{item['task_type']}/{item['status']}]")
|
|
208
|
+
|
|
209
|
+
goals = bundle.get("open_workflow_goals") or []
|
|
210
|
+
if goals:
|
|
211
|
+
lines.extend(["", "Open goals:"])
|
|
212
|
+
for item in goals[:5]:
|
|
213
|
+
lines.append(f"- {item['goal_id']}: {item['title']} [{item['status']}] -> {item['next_action'] or '(no next action)'}")
|
|
214
|
+
|
|
215
|
+
runs = bundle.get("open_workflow_runs") or []
|
|
216
|
+
if runs:
|
|
217
|
+
lines.extend(["", "Open workflows:"])
|
|
218
|
+
for item in runs[:5]:
|
|
219
|
+
lines.append(
|
|
220
|
+
f"- {item['run_id']}: {item['goal']} [{item['status']}] "
|
|
221
|
+
f"step={item['current_step_key'] or '?'} next={item['next_action'] or '(none)'}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def handle_session_export_bundle(sid: str = "", path: str = "") -> str:
|
|
228
|
+
"""Export a machine-readable session bundle for cross-client handoff."""
|
|
229
|
+
bundle = _session_portability_bundle(sid)
|
|
230
|
+
if not bundle.get("ok"):
|
|
231
|
+
return json.dumps(bundle, ensure_ascii=False)
|
|
232
|
+
|
|
233
|
+
session_id = bundle["session"]["sid"]
|
|
234
|
+
export_path = Path(path).expanduser() if path else (SESSION_PORTABILITY_DIR / f"{session_id}.json")
|
|
235
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
export_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + "\n")
|
|
237
|
+
return json.dumps(
|
|
238
|
+
{
|
|
239
|
+
"ok": True,
|
|
240
|
+
"sid": session_id,
|
|
241
|
+
"path": str(export_path),
|
|
242
|
+
"open_protocol_tasks": len(bundle.get("open_protocol_tasks") or []),
|
|
243
|
+
"open_workflow_goals": len(bundle.get("open_workflow_goals") or []),
|
|
244
|
+
"open_workflow_runs": len(bundle.get("open_workflow_runs") or []),
|
|
245
|
+
},
|
|
246
|
+
ensure_ascii=False,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
67
250
|
def handle_startup(
|
|
68
251
|
task: str = "Startup",
|
|
69
252
|
claude_session_id: str = "",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- nexo-claude-md-version: 2.
|
|
1
|
+
<!-- nexo-claude-md-version: 2.1.2 -->
|
|
2
2
|
******CORE******
|
|
3
3
|
<!-- nexo:core:start -->
|
|
4
4
|
# {{NAME}} — Cognitive Co-Operator
|
|
@@ -20,6 +20,14 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
|
|
|
20
20
|
**Presentation:** {{NAME}} speaks first. Conversational greeting adapted to time of day. Tell what you HAVE DONE, not list pending items. Menu only if the user asks.
|
|
21
21
|
<!-- nexo:end:startup -->
|
|
22
22
|
|
|
23
|
+
## Protocol (6 rules)
|
|
24
|
+
1. `nexo_startup` once per session and keep the returned `SID`.
|
|
25
|
+
2. `nexo_heartbeat` on every user message.
|
|
26
|
+
3. `nexo_task_open` for any non-trivial work. For `edit` / `execute` / `delegate`, this is the default path. If the work is long multi-step or likely to cross messages/sessions, also open `nexo_workflow_open` and keep it updated. If task_open surfaces blocking conditioned-file learnings, review them and acknowledge guard before any write / delete step.
|
|
27
|
+
4. Do not say `done`, `fixed`, or `sent` without evidence captured in `nexo_task_close`.
|
|
28
|
+
5. If a correction revealed a reusable pattern, capture or supersede the learning immediately so contradictory rules do not remain active.
|
|
29
|
+
6. On clear end-of-session intent, write the diary before replying.
|
|
30
|
+
|
|
23
31
|
<!-- nexo:start:profile -->
|
|
24
32
|
## User Profile
|
|
25
33
|
- **Calibration:** `{{NEXO_HOME}}/brain/calibration.json` (personality settings + language + user name)
|