nexo-brain 7.23.13 → 7.24.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -11
  3. package/bin/nexo-brain.js +42 -235
  4. package/package.json +1 -1
  5. package/src/automation_supervisor.py +1 -1
  6. package/src/cli.py +255 -9
  7. package/src/cognitive_control_observatory.py +224 -0
  8. package/src/dashboard/app.py +26 -9
  9. package/src/db/__init__.py +2 -0
  10. package/src/db/_learnings.py +1 -1
  11. package/src/db/_memory_v2.py +107 -1
  12. package/src/db/_protocol.py +2 -2
  13. package/src/db/_reminders.py +132 -4
  14. package/src/db/_schema.py +2 -2
  15. package/src/events_bus.py +4 -5
  16. package/src/learning_resolver.py +419 -0
  17. package/src/lifecycle_events.py +9 -9
  18. package/src/local_context/api.py +67 -5
  19. package/src/local_context/usage_events.py +24 -0
  20. package/src/memory_observation_processor.py +28 -0
  21. package/src/memory_retrieval.py +5 -5
  22. package/src/operator_language.py +2 -0
  23. package/src/plugins/backup.py +1 -1
  24. package/src/plugins/cortex.py +21 -21
  25. package/src/plugins/episodic_memory.py +11 -11
  26. package/src/plugins/goal_engine.py +3 -3
  27. package/src/plugins/personal_scripts.py +75 -0
  28. package/src/plugins/protocol.py +10 -1
  29. package/src/pre_answer_router.py +116 -0
  30. package/src/r_catalog.py +4 -5
  31. package/src/saved_not_used_audit.py +31 -31
  32. package/src/script_registry.py +444 -1
  33. package/src/scripts/deep-sleep/apply_findings.py +79 -17
  34. package/src/scripts/nexo-daily-self-audit.py +46 -13
  35. package/src/scripts/nexo-email-migrate-config.py +2 -2
  36. package/src/scripts/nexo-email-monitor.py +19 -19
  37. package/src/scripts/nexo-followup-hygiene.py +40 -8
  38. package/src/scripts/nexo-followup-runner.py +31 -31
  39. package/src/scripts/nexo-inbox-hook.sh +1 -1
  40. package/src/scripts/nexo-learning-validator.py +24 -3
  41. package/src/server.py +73 -1
  42. package/src/system_catalog.py +31 -31
  43. package/src/tools_learnings.py +96 -65
  44. package/src/tools_memory_v2.py +2 -2
  45. package/src/tools_sessions.py +25 -7
  46. package/templates/core-prompts/postmortem-consolidator.md +3 -3
  47. package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
  48. package/templates/core-prompts/server-mcp-instructions.md +6 -6
  49. package/tool-enforcement-map.json +143 -13
@@ -9,6 +9,7 @@ stable substrate without changing hook behaviour again.
9
9
  import hashlib
10
10
  import importlib
11
11
  import json
12
+ import os
12
13
  import re
13
14
  import sqlite3
14
15
  import sys
@@ -429,6 +430,101 @@ def _derive_observation(event: dict) -> dict:
429
430
  }
430
431
 
431
432
 
433
+ def _intraday_facts_enabled() -> bool:
434
+ value = os.environ.get("NEXO_INTRADAY_FACTS_ENABLED", "1").strip().lower()
435
+ return value not in {"0", "false", "no", "off", "disabled"}
436
+
437
+
438
+ def _intraday_fact_candidate(observation: dict) -> bool:
439
+ if str(observation.get("status") or "active").lower() != "active":
440
+ return False
441
+ if float(observation.get("salience") or 0.0) < 0.62:
442
+ return False
443
+ if str(observation.get("observation_type") or "") not in {
444
+ "code_change",
445
+ "correction",
446
+ "decision",
447
+ "task_result",
448
+ }:
449
+ return False
450
+ if not str(observation.get("summary") or "").strip():
451
+ return False
452
+
453
+ facts = observation.get("facts") if isinstance(observation.get("facts"), dict) else {}
454
+ metadata = facts.get("metadata") if isinstance(facts.get("metadata"), dict) else {}
455
+ event_type = str(facts.get("event_type") or "")
456
+ source_type = str(facts.get("source_type") or "")
457
+ refs = [str(ref) for ref in observation.get("evidence_refs") or []]
458
+ observation_type = str(observation.get("observation_type") or "")
459
+
460
+ if observation_type == "task_result":
461
+ outcome = str(metadata.get("outcome") or event_type.removeprefix("protocol_task_") or "").lower()
462
+ return outcome in {"done", "closed", "completed", "success", "partial"}
463
+ if observation_type == "code_change":
464
+ verification_keys = {
465
+ "verified",
466
+ "verification",
467
+ "change_verify",
468
+ "test_output",
469
+ "tests_passed",
470
+ "evidence",
471
+ }
472
+ if source_type in {"change_log", "evidence_ledger", "protocol_task"}:
473
+ return True
474
+ if any(key in metadata and str(metadata.get(key) or "").strip() for key in verification_keys):
475
+ return True
476
+ return any(ref.startswith(("change_log:", "evidence:", "protocol_task:")) for ref in refs)
477
+ return observation_type in {"correction", "decision"}
478
+
479
+
480
+ def publish_intraday_fact(observation: dict, *, ttl_hours: int = 36) -> dict:
481
+ """Expose high-salience observations as temporary hot context.
482
+
483
+ This is deliberately not long-term promotion. Deep Sleep can later promote,
484
+ merge, or discard the observation; the intraday fact only keeps today's
485
+ important work visible while the operator keeps working.
486
+ """
487
+ if not _intraday_facts_enabled():
488
+ return {"ok": True, "skipped": True, "reason": "intraday facts disabled"}
489
+ if not _intraday_fact_candidate(observation):
490
+ return {"ok": True, "skipped": True, "reason": "not an intraday fact candidate"}
491
+
492
+ uid = str(observation.get("observation_uid") or "").strip()
493
+ if not uid:
494
+ return {"ok": False, "error": "observation_uid is required"}
495
+
496
+ try:
497
+ from db._hot_context import capture_context_event
498
+
499
+ result = capture_context_event(
500
+ event_type="intraday_fact",
501
+ title=_truncate(observation.get("subject") or uid, 160),
502
+ summary=_truncate(observation.get("summary") or "", 600),
503
+ body=_truncate(observation.get("summary") or "", 1600),
504
+ context_key=f"intraday_fact:{uid}",
505
+ context_title=_truncate(observation.get("subject") or uid, 160),
506
+ context_summary=_truncate(observation.get("summary") or "", 600),
507
+ context_type="intraday_fact",
508
+ state="active",
509
+ owner="nexo",
510
+ actor="memory-observation-processor",
511
+ source_type="memory_observation",
512
+ source_id=uid,
513
+ session_id=str(observation.get("session_id") or ""),
514
+ metadata={
515
+ "observation_type": observation.get("observation_type") or "",
516
+ "project_key": observation.get("project_key") or "",
517
+ "promotion_state": observation.get("promotion_state") or "observation",
518
+ "evidence_refs": observation.get("evidence_refs") or [],
519
+ },
520
+ ttl_hours=ttl_hours,
521
+ created_at=float(observation.get("updated_at") or _core().now_epoch()),
522
+ )
523
+ return {"ok": True, "context_key": result.get("context_key"), "result": result}
524
+ except Exception as exc:
525
+ return {"ok": False, "error": _truncate(str(exc), 500)}
526
+
527
+
432
528
  def upsert_memory_observation(observation: dict) -> dict:
433
529
  conn = _core().get_db()
434
530
  if not _table_exists(conn, "memory_observations"):
@@ -520,6 +616,7 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
520
616
  ).fetchall()
521
617
  processed = 0
522
618
  failed = 0
619
+ intraday_facts = 0
523
620
  now = _core().now_epoch()
524
621
  for row in rows:
525
622
  event = _row_to_event(row)
@@ -527,6 +624,9 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
527
624
  try:
528
625
  observation = _derive_observation(event)
529
626
  upsert_memory_observation(observation)
627
+ intraday_result = publish_intraday_fact(observation)
628
+ if intraday_result.get("ok") and not intraday_result.get("skipped"):
629
+ intraday_facts += 1
530
630
  conn.execute(
531
631
  """
532
632
  UPDATE memory_observation_queue
@@ -554,7 +654,13 @@ def process_memory_observation_queue(limit: int = 25) -> dict:
554
654
  )
555
655
  failed += 1
556
656
  conn.commit()
557
- return {"ok": failed == 0, "processed": processed, "failed": failed, "total_seen": len(rows)}
657
+ return {
658
+ "ok": failed == 0,
659
+ "processed": processed,
660
+ "failed": failed,
661
+ "intraday_facts": intraday_facts,
662
+ "total_seen": len(rows),
663
+ }
558
664
 
559
665
 
560
666
  def list_memory_events(
@@ -365,9 +365,9 @@ def cortex_evaluation_summary(days: int = 30) -> dict:
365
365
 
366
366
  gaps: list[str] = []
367
367
  if total < 3:
368
- gaps.append("Muy pocas evaluaciones del cortex para inferir mejora estable.")
368
+ gaps.append("Too few Cortex evaluations to infer stable improvement.")
369
369
  if len(linked) < 2:
370
- gaps.append("Muy pocas decisiones enlazadas a outcomes para medir calidad real de recomendación.")
370
+ gaps.append("Too few decisions linked to outcomes to measure real recommendation quality.")
371
371
 
372
372
  return {
373
373
  "days": max(1, int(days)),
@@ -14,7 +14,53 @@ from db._hot_context import capture_context_event
14
14
  from db._learnings import extract_keywords
15
15
  from db._semantic_similarity import hybrid_similarity_score
16
16
 
17
- ACTIVE_EXCLUDED_STATUSES = {"DELETED", "archived", "blocked", "waiting"}
17
+ ACTIVE_EXCLUDED_STATUSES = {
18
+ "ARCHIVED",
19
+ "BLOCKED",
20
+ "DELETED",
21
+ "DONE",
22
+ "EXPIRED",
23
+ "NEEDS_DECISION",
24
+ "PARKED",
25
+ "STALE_REVIEW",
26
+ "WAITING",
27
+ "WAITING_EXTERNAL",
28
+ "WAITING_USER",
29
+ }
30
+ FOLLOWUP_TERMINAL_STATUSES = {"COMPLETED", "DELETED", "DONE", "EXPIRED", "ARCHIVED"}
31
+ FOLLOWUP_WAITING_USER_STATUSES = {"NEEDS_DECISION", "WAITING_USER"}
32
+ FOLLOWUP_WAITING_EXTERNAL_STATUSES = {"WAITING", "WAITING_EXTERNAL"}
33
+ FOLLOWUP_BLOCKED_STATUSES = {"BLOCKED"}
34
+ FOLLOWUP_STATUS_ALIASES = {
35
+ "": "PENDING",
36
+ "PENDIENTE": "PENDING",
37
+ "PENDING": "PENDING",
38
+ "ACTIVE": "PENDING",
39
+ "ACTIVO": "PENDING",
40
+ "COMPLETADO": "COMPLETED",
41
+ "COMPLETED": "COMPLETED",
42
+ "DONE": "DONE",
43
+ "HECHO": "DONE",
44
+ "ELIMINADO": "DELETED",
45
+ "DELETED": "DELETED",
46
+ "ARCHIVED": "ARCHIVED",
47
+ "ARCHIVADO": "ARCHIVED",
48
+ "BLOCKED": "BLOCKED",
49
+ "BLOQUEADO": "BLOCKED",
50
+ "WAITING": "WAITING",
51
+ "WAITING_EXTERNAL": "WAITING_EXTERNAL",
52
+ "ESPERANDO": "WAITING_EXTERNAL",
53
+ "WAITING_USER": "WAITING_USER",
54
+ "NEEDS_DECISION": "NEEDS_DECISION",
55
+ "NEEDS-DECISION": "NEEDS_DECISION",
56
+ "NEEDS DECISION": "NEEDS_DECISION",
57
+ "DECISION": "NEEDS_DECISION",
58
+ "NECESITA_DECISION": "NEEDS_DECISION",
59
+ "PARKED": "PARKED",
60
+ "APARCADO": "PARKED",
61
+ "STALE_REVIEW": "STALE_REVIEW",
62
+ "EXPIRED": "EXPIRED",
63
+ }
18
64
  READ_TOKEN_TTL_SECONDS = 30 * 60
19
65
 
20
66
  # Opportunistic cleanup of expired item_read_tokens: runs at most once every
@@ -71,6 +117,49 @@ def _truncate(text: str | None, limit: int = 240) -> str:
71
117
  return text if len(text) <= limit else text[: limit - 3] + "..."
72
118
 
73
119
 
120
+ def normalize_followup_status(status: str | None) -> str:
121
+ """Return the canonical followup status used by lifecycle tools."""
122
+ clean = str(status or "").strip()
123
+ if not clean:
124
+ return "PENDING"
125
+ upper = clean.upper().replace("-", "_")
126
+ if upper.startswith("COMPLETED"):
127
+ return "COMPLETED"
128
+ return FOLLOWUP_STATUS_ALIASES.get(upper, upper)
129
+
130
+
131
+ def followup_lifecycle_lane(followup: dict) -> str:
132
+ """Classify one followup into an operational lane without mutating it."""
133
+ status = normalize_followup_status(followup.get("status"))
134
+ owner = str(followup.get("owner") or "").strip().lower()
135
+ if status in FOLLOWUP_TERMINAL_STATUSES:
136
+ return "completed" if status in {"COMPLETED", "DONE"} else status.lower()
137
+ if status in FOLLOWUP_BLOCKED_STATUSES:
138
+ return "blocked"
139
+ if status in FOLLOWUP_WAITING_USER_STATUSES or owner == "user":
140
+ return "waiting_user"
141
+ if status in FOLLOWUP_WAITING_EXTERNAL_STATUSES or owner == "waiting":
142
+ return "waiting_external"
143
+ if status == "PARKED":
144
+ return "parked"
145
+ if status == "STALE_REVIEW":
146
+ return "stale_review"
147
+ return "active"
148
+
149
+
150
+ def followup_due_state(followup: dict) -> str:
151
+ """Return due/backlog/future/completed without changing the lifecycle lane."""
152
+ lane = followup_lifecycle_lane(followup)
153
+ if lane != "active":
154
+ return lane
155
+ due = _parse_date(followup.get("date"))
156
+ if due is None:
157
+ return "backlog"
158
+ if due > datetime.date.today():
159
+ return "future"
160
+ return "due"
161
+
162
+
74
163
  def _format_changes(before: sqlite3.Row | dict | None, after: sqlite3.Row | dict | None, fields: list[str]) -> str:
75
164
  if before is None or after is None:
76
165
  return ""
@@ -226,18 +315,20 @@ def _active_status_where(column_name: str = "status") -> str:
226
315
  excluded = ", ".join(f"'{value}'" for value in sorted(ACTIVE_EXCLUDED_STATUSES))
227
316
  return (
228
317
  f"{column_name} NOT LIKE 'COMPLETED%' "
229
- f"AND {column_name} NOT IN ({excluded})"
318
+ f"AND UPPER(COALESCE({column_name}, '')) NOT IN ({excluded})"
230
319
  )
231
320
 
232
321
 
233
322
  def _context_state_from_status(status: str | None) -> str:
234
- normalized = str(status or "PENDING").strip().upper()
323
+ normalized = normalize_followup_status(status)
235
324
  if normalized.startswith("COMPLETED"):
236
325
  return "resolved"
237
326
  if normalized == "DELETED":
238
327
  return "abandoned"
239
- if normalized == "WAITING":
328
+ if normalized in {"WAITING_USER", "NEEDS_DECISION"}:
240
329
  return "waiting_user"
330
+ if normalized in {"WAITING", "WAITING_EXTERNAL"}:
331
+ return "waiting_third_party"
241
332
  if normalized == "BLOCKED":
242
333
  return "blocked"
243
334
  return "active"
@@ -632,6 +723,7 @@ def create_followup(
632
723
  """
633
724
  conn = get_db()
634
725
  now = now_epoch()
726
+ status = normalize_followup_status(status)
635
727
  similar = find_similar_followups(description)
636
728
  warning = ""
637
729
  if similar:
@@ -742,6 +834,8 @@ def update_followup(
742
834
  updates.pop("owner")
743
835
  else:
744
836
  updates["owner"] = coerced
837
+ if "status" in updates:
838
+ updates["status"] = normalize_followup_status(updates["status"])
745
839
  if not updates:
746
840
  return {"error": "No valid fields to update"}
747
841
 
@@ -1102,6 +1196,40 @@ def get_followup_history(id: str, limit: int = 20) -> list[dict]:
1102
1196
  return get_item_history("followup", id, limit=limit)
1103
1197
 
1104
1198
 
1199
+ def followup_lifecycle_snapshot(limit: int = 500) -> dict:
1200
+ """Return followups grouped by lifecycle lane for dashboards and runners."""
1201
+ conn = get_db()
1202
+ rows = conn.execute(
1203
+ "SELECT * FROM followups ORDER BY updated_at DESC LIMIT ?",
1204
+ (max(1, min(int(limit or 500), 5000)),),
1205
+ ).fetchall()
1206
+ lanes = {
1207
+ "active": [],
1208
+ "waiting_user": [],
1209
+ "waiting_external": [],
1210
+ "blocked": [],
1211
+ "parked": [],
1212
+ "stale_review": [],
1213
+ "expired": [],
1214
+ "completed": [],
1215
+ "deleted": [],
1216
+ "archived": [],
1217
+ }
1218
+ for row in rows:
1219
+ item = dict(row)
1220
+ item["status"] = normalize_followup_status(item.get("status"))
1221
+ lane = followup_lifecycle_lane(item)
1222
+ item["lifecycle_lane"] = lane
1223
+ item["due_state"] = followup_due_state(item)
1224
+ lanes.setdefault(lane, []).append(item)
1225
+ return {
1226
+ "ok": True,
1227
+ "total": len(rows),
1228
+ "lanes": lanes,
1229
+ "counts": {lane: len(items) for lane, items in lanes.items()},
1230
+ }
1231
+
1232
+
1105
1233
  def _parse_date(date_str: str | None) -> datetime.date | None:
1106
1234
  text = str(date_str or "").strip()
1107
1235
  if not text:
package/src/db/_schema.py CHANGED
@@ -1341,7 +1341,7 @@ def _m49_protocol_guard_ack_backfill(conn):
1341
1341
 
1342
1342
  def _m50_dedupe_nexo_product_learning_pair(conn):
1343
1343
  """Block D.2 / G7-adjacent: dedupe the two learnings that encode the
1344
- "NEXO Brain producto público vs instancia personal de Francisco"
1344
+ "NEXO Brain public product vs Francisco's personal instance"
1345
1345
  invariant as a physically separate pair.
1346
1346
 
1347
1347
  Francisco's runtime has this concept stored twice (historical IDs 212
@@ -1368,7 +1368,7 @@ def _m50_dedupe_nexo_product_learning_pair(conn):
1368
1368
 
1369
1369
  def _norm(text: str) -> str:
1370
1370
  # Collapse whitespace and strip punctuation/case so "NEXO Brain
1371
- # producto público vs instancia personal" matches its twin no
1371
+ # public product vs personal instance" matches its twin no
1372
1372
  # matter how the operator rephrased it.
1373
1373
  import re as _re
1374
1374
  stripped = _re.sub(r"[\W_]+", " ", str(text or "")).strip().lower()
package/src/events_bus.py CHANGED
@@ -101,11 +101,10 @@ def emit(
101
101
  path.parent.mkdir(parents=True, exist_ok=True)
102
102
  _rotate_if_needed(path)
103
103
 
104
- # v0.32.5 — antes `_next_id(path)` se calculaba ANTES de adquirir el
105
- # flock → dos emitters concurrentes leían el mismo tail, computaban
106
- # el mismo id, y escribían dos eventos con el mismo id. El renderer
107
- # dedup descartaba el segundo silently eventos perdidos. Ahora
108
- # `_next_id` se llama DENTRO del lock, garantizando monotonía.
104
+ # v0.32.5 — `_next_id(path)` used to run BEFORE taking the flock. Two
105
+ # concurrent emitters could read the same tail, compute the same id, and
106
+ # write two events with that id. Renderer dedup then dropped the second
107
+ # event silently. Calling `_next_id` INSIDE the lock guarantees monotonicity.
109
108
  line = None
110
109
  event = None
111
110
  with path.open("a", encoding="utf-8") as fh: