nexo-brain 2.7.0 → 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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +66 -12
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +290 -6
  6. package/src/cli.py +111 -0
  7. package/src/client_preferences.py +94 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +140 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/protocol.html +199 -0
  14. package/src/db/__init__.py +23 -1
  15. package/src/db/_learnings.py +31 -4
  16. package/src/db/_personal_scripts.py +12 -0
  17. package/src/db/_protocol.py +303 -0
  18. package/src/db/_schema.py +248 -0
  19. package/src/db/_watchers.py +173 -0
  20. package/src/db/_workflow.py +952 -0
  21. package/src/doctor/providers/runtime.py +918 -7
  22. package/src/evolution_cycle.py +62 -0
  23. package/src/hook_guardrails.py +308 -0
  24. package/src/hooks/protocol-guardrail.sh +10 -0
  25. package/src/nexo_sdk.py +103 -0
  26. package/src/plugins/cognitive_memory.py +18 -0
  27. package/src/plugins/cortex.py +55 -35
  28. package/src/plugins/guard.py +132 -16
  29. package/src/plugins/protocol.py +911 -0
  30. package/src/plugins/schedule.py +40 -6
  31. package/src/plugins/simple_api.py +103 -0
  32. package/src/plugins/skills.py +67 -0
  33. package/src/plugins/state_watchers.py +79 -0
  34. package/src/plugins/workflow.py +588 -0
  35. package/src/public_contribution.py +86 -12
  36. package/src/script_registry.py +142 -0
  37. package/src/scripts/deep-sleep/apply_findings.py +204 -0
  38. package/src/scripts/deep-sleep/collect.py +49 -4
  39. package/src/scripts/nexo-agent-run.py +2 -0
  40. package/src/scripts/nexo-daily-self-audit.py +843 -5
  41. package/src/scripts/nexo-evolution-run.py +343 -1
  42. package/src/server.py +92 -6
  43. package/src/skills_runtime.py +151 -0
  44. package/src/state_watchers_runtime.py +334 -0
  45. package/src/tools_learnings.py +345 -7
  46. package/src/tools_sessions.py +183 -0
  47. package/templates/CLAUDE.md.template +9 -1
  48. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -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 = '') -> 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)
@@ -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.0.0 -->
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)