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
@@ -62,6 +62,7 @@ DB_PATH = data_dir() / "nexo.db"
62
62
 
63
63
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
64
64
  from core_prompts import render_core_prompt
65
+ from learning_resolver import resolve_learning_candidate
65
66
 
66
67
  try:
67
68
  from client_preferences import resolve_user_model as _resolve_user_model
@@ -79,12 +80,12 @@ def get_all_learnings(category: str | None = None) -> list[dict]:
79
80
  conn.row_factory = sqlite3.Row
80
81
  if category:
81
82
  rows = conn.execute(
82
- "SELECT id, category, title, content FROM learnings WHERE category = ?",
83
+ "SELECT id, category, title, content FROM learnings WHERE category = ? AND COALESCE(status, 'active') = 'active'",
83
84
  (category,),
84
85
  ).fetchall()
85
86
  else:
86
87
  rows = conn.execute(
87
- "SELECT id, category, title, content FROM learnings"
88
+ "SELECT id, category, title, content FROM learnings WHERE COALESCE(status, 'active') = 'active'"
88
89
  ).fetchall()
89
90
  conn.close()
90
91
  return [dict(r) for r in rows]
@@ -131,6 +132,12 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
131
132
  "recommendation": str
132
133
  }
133
134
  """
135
+ resolver = resolve_learning_candidate(
136
+ category=category or "process",
137
+ title=(finding or "Finding").strip()[:120] or "Finding",
138
+ content=finding or "",
139
+ source_authority="code_test_evidence" if any(token in (finding or "").lower() for token in ("test", "traceback", "stack", "verified", "evidence")) else "inference",
140
+ )
134
141
  learnings = get_all_learnings(category)
135
142
 
136
143
  if not learnings:
@@ -139,6 +146,7 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
139
146
  "confidence": 0,
140
147
  "matching_learnings": [],
141
148
  "recommendation": "No learnings in DB — finding is new by default",
149
+ "resolver": resolver,
142
150
  }
143
151
 
144
152
  learnings_ref = [
@@ -168,13 +176,26 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
168
176
  )
169
177
  parsed = _extract_json(result.stdout)
170
178
  if result.returncode == 0 and parsed:
179
+ parsed["resolver"] = resolver
171
180
  return parsed
172
181
  except AutomationBackendUnavailableError:
173
182
  pass
174
183
  except Exception:
175
184
  pass
176
185
 
177
- return _mechanical_validate(finding, learnings)
186
+ result = _mechanical_validate(finding, learnings)
187
+ result["resolver"] = resolver
188
+ if resolver.get("action") in {"merge", "supersede", "conflict_review"}:
189
+ result["known"] = True
190
+ result["confidence"] = max(float(result.get("confidence") or 0), float(resolver.get("similarity") or 0.7))
191
+ result["matching_learnings"] = result.get("matching_learnings") or [{
192
+ "id": resolver.get("target_id"),
193
+ "category": category or "process",
194
+ "title": resolver.get("target_title"),
195
+ "similarity": resolver.get("similarity"),
196
+ }]
197
+ result["recommendation"] = f"Resolver action: {resolver.get('action')} ({resolver.get('reason')})"
198
+ return result
178
199
 
179
200
 
180
201
  def _mechanical_validate(finding: str, learnings: list[dict]) -> dict:
package/src/server.py CHANGED
@@ -79,7 +79,7 @@ from tools_reminders_crud import (
79
79
  from tools_learnings import (
80
80
  handle_learning_add, handle_learning_search,
81
81
  handle_learning_update, handle_learning_delete, handle_learning_list,
82
- handle_learning_quality,
82
+ handle_learning_quality, handle_learning_resolve_candidate,
83
83
  )
84
84
  from tools_credentials import (
85
85
  handle_credential_get, handle_credential_create,
@@ -1089,6 +1089,19 @@ def nexo_context_router(query: str, intent: str = "answer", limit: int = 4, curr
1089
1089
  return json.dumps(result, ensure_ascii=False)
1090
1090
 
1091
1091
 
1092
+ @mcp.tool
1093
+ def nexo_cognitive_control_observatory(window_seconds: int = 86400) -> str:
1094
+ """Read-only metrics for Local Context, learnings, followups and intraday memory."""
1095
+ from cognitive_control_observatory import build_cognitive_control_observatory
1096
+
1097
+ return json.dumps(
1098
+ build_cognitive_control_observatory(window_seconds=window_seconds),
1099
+ ensure_ascii=False,
1100
+ indent=2,
1101
+ sort_keys=True,
1102
+ )
1103
+
1104
+
1092
1105
  @mcp.tool
1093
1106
  def nexo_local_asset_get(asset_id: str) -> str:
1094
1107
  """Return one indexed local asset by asset id."""
@@ -1386,6 +1399,23 @@ def nexo_memory_observation_process(limit: int = 25, backfill_limit: int = 100,
1386
1399
  )
1387
1400
 
1388
1401
 
1402
+ @mcp.tool
1403
+ def nexo_intraday_memory_cycle(limit: int = 20, backfill_limit: int = 20, pending_sla_seconds: int = 3600) -> str:
1404
+ """Run a low-limit daytime memory observation cycle that only publishes evidence-backed intraday facts."""
1405
+ from memory_observation_processor import process_intraday_cycle
1406
+
1407
+ return json.dumps(
1408
+ process_intraday_cycle(
1409
+ process_limit=limit,
1410
+ backfill_limit=backfill_limit,
1411
+ pending_sla_seconds=pending_sla_seconds,
1412
+ ),
1413
+ ensure_ascii=False,
1414
+ indent=2,
1415
+ sort_keys=True,
1416
+ )
1417
+
1418
+
1389
1419
  @mcp.tool
1390
1420
  def nexo_memory_observation_list(
1391
1421
  query: str = "",
@@ -1968,6 +1998,19 @@ def nexo_followup_get(id: str) -> str:
1968
1998
  return handle_followup_get(id)
1969
1999
 
1970
2000
 
2001
+ @mcp.tool
2002
+ def nexo_followup_lifecycle(limit: int = 500) -> str:
2003
+ """Return followups grouped by lifecycle lane for runner, dashboard and startup parity."""
2004
+ from db import followup_lifecycle_snapshot
2005
+
2006
+ return json.dumps(
2007
+ followup_lifecycle_snapshot(limit=limit),
2008
+ ensure_ascii=False,
2009
+ indent=2,
2010
+ sort_keys=True,
2011
+ )
2012
+
2013
+
1971
2014
  @mcp.tool
1972
2015
  def nexo_followup_update(
1973
2016
  id: str,
@@ -2066,6 +2109,7 @@ def nexo_learning_add(
2066
2109
  review_days: int = 30,
2067
2110
  priority: str = "medium",
2068
2111
  supersedes_id: int = 0,
2112
+ source_authority: str = "explicit_instruction",
2069
2113
  ) -> str:
2070
2114
  """Add a new learning (resolved error, pattern, gotcha).
2071
2115
 
@@ -2079,11 +2123,39 @@ def nexo_learning_add(
2079
2123
  review_days: Days until this learning should be reviewed again (default 30).
2080
2124
  priority: critical, high, medium, low (default: medium). Critical/high never decay below floor.
2081
2125
  supersedes_id: Existing learning ID this new canonical rule replaces (optional).
2126
+ source_authority: Authority tier for conflict resolution: francisco_correction, explicit_instruction, code_test_evidence, deep_sleep, inference.
2082
2127
  """
2083
2128
  return handle_learning_add(
2084
2129
  category, title, content, reasoning,
2085
2130
  prevention=prevention, applies_to=applies_to,
2086
2131
  review_days=review_days, priority=priority, supersedes_id=supersedes_id,
2132
+ source_authority=source_authority,
2133
+ )
2134
+
2135
+
2136
+ @mcp.tool
2137
+ def nexo_learning_resolve_candidate(
2138
+ category: str,
2139
+ title: str,
2140
+ content: str,
2141
+ reasoning: str = "",
2142
+ prevention: str = "",
2143
+ applies_to: str = "",
2144
+ priority: str = "medium",
2145
+ supersedes_id: int = 0,
2146
+ source_authority: str = "inference",
2147
+ ) -> str:
2148
+ """Dry-run the canonical learning resolver without creating or updating learnings."""
2149
+ return handle_learning_resolve_candidate(
2150
+ category=category,
2151
+ title=title,
2152
+ content=content,
2153
+ reasoning=reasoning,
2154
+ prevention=prevention,
2155
+ applies_to=applies_to,
2156
+ priority=priority,
2157
+ supersedes_id=supersedes_id,
2158
+ source_authority=source_authority,
2087
2159
  )
2088
2160
 
2089
2161
 
@@ -262,110 +262,110 @@ def _guide_for_tool(name: str) -> dict[str, list]:
262
262
  if name == "nexo_learning_add":
263
263
  return {
264
264
  "workflow": [
265
- "Usa `applies_to` si quieres que el guard recuerde este learning antes de tocar un archivo, directorio o patrón concreto.",
266
- "Usa `priority` (`critical`, `high`, `medium`, `low`) para marcar severidad operativa.",
265
+ "Use `applies_to` if you want the pre-edit check to surface this learning before touching a concrete file, directory, or pattern.",
266
+ "Use `priority` (`critical`, `high`, `medium`, `low`) to mark operational severity.",
267
267
  ],
268
268
  "examples": [
269
269
  {
270
- "title": "Learning mínimo",
271
- "code": 'nexo_learning_add(category="shopify", title="Hacer pull antes de editar", content="Siempre sincronizar antes de editar el tema live.")',
270
+ "title": "Minimal learning",
271
+ "code": 'nexo_learning_add(category="shopify", title="Pull before editing", content="Always sync before editing the live theme.")',
272
272
  },
273
273
  {
274
- "title": "Learning ligado a archivo o patrón",
275
- "code": 'nexo_learning_add(category="recambios-bmw", title="Pull antes de editar theme", content="El admin puede tocar JSONs live.", applies_to="/abs/path/templates/product.json,templates/*.json,sections/*.liquid", prevention="Ejecutar `shopify theme pull` antes de editar.", priority="high")',
274
+ "title": "Learning linked to a file or pattern",
275
+ "code": 'nexo_learning_add(category="recambios-bmw", title="Pull before editing theme", content="The admin can touch live JSON files.", applies_to="/abs/path/templates/product.json,templates/*.json,sections/*.liquid", prevention="Run `shopify theme pull` before editing.", priority="high")',
276
276
  },
277
277
  ],
278
278
  "common_errors": [
279
- "Usar `severity` en vez de `priority`.",
280
- "Olvidar `title`, que es obligatorio.",
281
- "No poner `applies_to` cuando quieres que el warning salte antes de tocar archivos concretos.",
279
+ "Using `severity` instead of `priority`.",
280
+ "Omitting `title`, which is required.",
281
+ "Omitting `applies_to` when you want the warning to appear before touching concrete files.",
282
282
  ],
283
283
  }
284
284
  if name == "nexo_learning_update":
285
285
  return {
286
286
  "workflow": [
287
- "Úsalo para completar o endurecer un learning existente cuando descubres nuevos archivos afectados, mejor `prevention` o prioridad distinta.",
287
+ "Use it to complete or harden an existing learning when you discover newly affected files, a better `prevention`, or a different priority.",
288
288
  ],
289
289
  "examples": [
290
290
  {
291
- "title": "Añadir alcance a un learning existente",
292
- "code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="Leer schema antes del primer uso", priority="high")',
291
+ "title": "Add scope to an existing learning",
292
+ "code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="Read the schema before first use", priority="high")',
293
293
  },
294
294
  ],
295
295
  "common_errors": [
296
- "Intentar recrear el learning desde cero cuando basta con actualizar el existente.",
296
+ "Recreating the learning from scratch when updating the existing one is enough.",
297
297
  ],
298
298
  }
299
299
  if name == "nexo_reminder_get":
300
300
  return {
301
301
  "workflow": [
302
- "Devuelve el `READ_TOKEN` necesario para `update`, `delete`, `restore` y `note` sobre ese reminder.",
302
+ "Returns the `READ_TOKEN` required for `update`, `delete`, `restore`, and `note` on that reminder.",
303
303
  ],
304
304
  "examples": [
305
305
  {
306
- "title": "Leer reminder y obtener token",
306
+ "title": "Read reminder and get token",
307
307
  "code": 'nexo_reminder_get(id="R87")',
308
308
  },
309
309
  ],
310
310
  "common_errors": [
311
- "Intentar editar o borrar un reminder sin llamar antes a `nexo_reminder_get`.",
311
+ "Trying to edit or delete a reminder without calling `nexo_reminder_get` first.",
312
312
  ],
313
313
  }
314
314
  if name in {"nexo_reminder_update", "nexo_reminder_delete", "nexo_reminder_restore", "nexo_reminder_note"}:
315
315
  return {
316
316
  "workflow": [
317
- "Primero llama `nexo_reminder_get(id=\"R87\")` para obtener `READ_TOKEN`.",
318
- f"Luego reutiliza ese `READ_TOKEN` en `{name}(...)`.",
317
+ "First call `nexo_reminder_get(id=\"R87\")` to obtain `READ_TOKEN`.",
318
+ f"Then reuse that `READ_TOKEN` in `{name}(...)`.",
319
319
  ],
320
320
  "examples": [
321
321
  {
322
- "title": "1. Obtener token",
322
+ "title": "1. Get token",
323
323
  "code": 'nexo_reminder_get(id="R87")',
324
324
  },
325
325
  {
326
- "title": "2. Reutilizar READ_TOKEN",
326
+ "title": "2. Reuse READ_TOKEN",
327
327
  "code": f'{name}(id="R87", read_token="TOKEN")',
328
328
  },
329
329
  ],
330
330
  "common_errors": [
331
- "Llamar a esta tool sin `READ_TOKEN` válido.",
332
- "Usar un `READ_TOKEN` de otro reminder o de una lectura antigua.",
331
+ "Calling this tool without a valid `READ_TOKEN`.",
332
+ "Using a `READ_TOKEN` from another reminder or an older read.",
333
333
  ],
334
334
  }
335
335
  if name == "nexo_followup_get":
336
336
  return {
337
337
  "workflow": [
338
- "Devuelve el `READ_TOKEN` necesario para `update`, `delete`, `restore` y `note` sobre ese followup.",
338
+ "Returns the `READ_TOKEN` required for `update`, `delete`, `restore`, and `note` on that followup.",
339
339
  ],
340
340
  "examples": [
341
341
  {
342
- "title": "Leer followup y obtener token",
342
+ "title": "Read followup and get token",
343
343
  "code": 'nexo_followup_get(id="NF45")',
344
344
  },
345
345
  ],
346
346
  "common_errors": [
347
- "Intentar editar o borrar un followup sin llamar antes a `nexo_followup_get`.",
347
+ "Trying to edit or delete a followup without calling `nexo_followup_get` first.",
348
348
  ],
349
349
  }
350
350
  if name in {"nexo_followup_update", "nexo_followup_delete", "nexo_followup_restore", "nexo_followup_note"}:
351
351
  return {
352
352
  "workflow": [
353
- "Primero llama `nexo_followup_get(id=\"NF45\")` para obtener `READ_TOKEN`.",
354
- f"Luego reutiliza ese `READ_TOKEN` en `{name}(...)`.",
353
+ "First call `nexo_followup_get(id=\"NF45\")` to obtain `READ_TOKEN`.",
354
+ f"Then reuse that `READ_TOKEN` in `{name}(...)`.",
355
355
  ],
356
356
  "examples": [
357
357
  {
358
- "title": "1. Obtener token",
358
+ "title": "1. Get token",
359
359
  "code": 'nexo_followup_get(id="NF45")',
360
360
  },
361
361
  {
362
- "title": "2. Reutilizar READ_TOKEN",
362
+ "title": "2. Reuse READ_TOKEN",
363
363
  "code": f'{name}(id="NF45", read_token="TOKEN")',
364
364
  },
365
365
  ],
366
366
  "common_errors": [
367
- "Llamar a esta tool sin `READ_TOKEN` válido.",
368
- "Usar un `READ_TOKEN` de otro followup o de una lectura antigua.",
367
+ "Calling this tool without a valid `READ_TOKEN`.",
368
+ "Using a `READ_TOKEN` from another followup or an older read.",
369
369
  ],
370
370
  }
371
371
  return {}
@@ -1,16 +1,25 @@
1
1
  """Learnings CRUD tools: add, search, update, delete, list."""
2
2
 
3
+ import json
3
4
  import os
4
5
  import re
6
+ import unicodedata
5
7
  from datetime import datetime
6
8
 
7
9
  from db import (create_learning, update_learning, delete_learning, search_learnings,
8
10
  list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning, extract_keywords,
9
11
  resolve_session_correction_requirements)
12
+ from learning_resolver import (
13
+ applies_overlap as _canonical_applies_overlap,
14
+ looks_contradictory as _canonical_looks_contradictory,
15
+ resolve_learning_candidate,
16
+ )
10
17
 
11
18
  NEGATION_PATTERNS = (
12
19
  "do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
13
20
  "disable", "disabled", "remove", "ban", "bypass",
21
+ " no ", " nunca ", " evita ", " evitar ", " sin ", " prohibe ", " prohibido ",
22
+ " desactiva ", " desactivar ", " elimina ", " eliminar ", " bloquea ", " bloquear ",
14
23
  )
15
24
  CONTRADICTION_PAIRS = (
16
25
  ("enable", "disable"),
@@ -23,6 +32,15 @@ CONTRADICTION_PAIRS = (
23
32
  ("validate", "skip"),
24
33
  ("validate", "bypass"),
25
34
  ("include", "exclude"),
35
+ ("activar", "desactivar"),
36
+ ("usar", "evitar"),
37
+ ("usar", "no usar"),
38
+ ("editar", "no editar"),
39
+ ("tocar", "no tocar"),
40
+ ("anadir", "eliminar"),
41
+ ("permitir", "prohibir"),
42
+ ("validar", "saltar"),
43
+ ("incluir", "excluir"),
26
44
  )
27
45
 
28
46
 
@@ -40,26 +58,13 @@ def _normalize_applies_token(value: str) -> str:
40
58
 
41
59
 
42
60
  def _applies_overlap(left: str, right: str) -> bool:
43
- left_tokens = {_normalize_applies_token(item) for item in _split_applies_to(left)}
44
- right_tokens = {_normalize_applies_token(item) for item in _split_applies_to(right)}
45
- left_tokens.discard("")
46
- right_tokens.discard("")
47
- if not left_tokens or not right_tokens:
48
- return False
49
- if left_tokens & right_tokens:
50
- return True
51
- for left_token in left_tokens:
52
- for right_token in right_tokens:
53
- if "/" in left_token or "/" in right_token:
54
- if left_token.startswith(f"{right_token}/") or right_token.startswith(f"{left_token}/"):
55
- return True
56
- if left_token.endswith(f"/{right_token}") or right_token.endswith(f"/{left_token}"):
57
- return True
58
- return False
61
+ return _canonical_applies_overlap(left, right)
59
62
 
60
63
 
61
64
  def _normalize_text(text: str) -> str:
62
- return re.sub(r"\s+", " ", str(text or "").strip().lower())
65
+ normalized = unicodedata.normalize("NFKD", str(text or ""))
66
+ ascii_text = "".join(ch for ch in normalized if not unicodedata.combining(ch))
67
+ return re.sub(r"\s+", " ", ascii_text.strip().lower())
63
68
 
64
69
 
65
70
  def _tokenize(text: str) -> list[str]:
@@ -67,7 +72,7 @@ def _tokenize(text: str) -> list[str]:
67
72
 
68
73
 
69
74
  def _contains_negation(text: str) -> bool:
70
- lowered = _normalize_text(text)
75
+ lowered = f" {_normalize_text(text)} "
71
76
  return any(token in lowered for token in NEGATION_PATTERNS)
72
77
 
73
78
 
@@ -75,37 +80,16 @@ def _negated_action_verbs(text: str) -> set[str]:
75
80
  lowered = _normalize_text(text)
76
81
  matches = set()
77
82
  for pattern in (
78
- r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)",
79
- r"(?:do not|don't)\s+([a-z0-9_-]+)",
83
+ r"(?:never|avoid|skip|disable|remove|forbid|bypass|nunca|evita|evitar|desactiva|desactivar|elimina|eliminar|prohibe|prohibir|bloquea|bloquear)\s+([a-z0-9_-]+)",
84
+ r"(?:do not|don't|no)\s+([a-z0-9_-]+)",
85
+ r"(?:without|sin)\s+([a-z0-9_-]+)",
80
86
  ):
81
87
  matches.update(re.findall(pattern, lowered))
82
88
  return {match for match in matches if len(match) > 2}
83
89
 
84
90
 
85
91
  def _looks_contradictory(existing_text: str, new_text: str) -> bool:
86
- existing_norm = _normalize_text(existing_text)
87
- new_norm = _normalize_text(new_text)
88
- if not existing_norm or not new_norm:
89
- return False
90
- existing_tokens = set(_tokenize(existing_norm))
91
- new_tokens = set(_tokenize(new_norm))
92
- if not (existing_tokens & new_tokens):
93
- return False
94
- existing_negated_verbs = _negated_action_verbs(existing_norm)
95
- new_negated_verbs = _negated_action_verbs(new_norm)
96
- if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
97
- return True
98
- if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
99
- return True
100
- if _contains_negation(existing_norm) != _contains_negation(new_norm):
101
- return True
102
- for positive, negative in CONTRADICTION_PAIRS:
103
- existing_has_pair = positive in existing_norm or negative in existing_norm
104
- new_has_pair = positive in new_norm or negative in new_norm
105
- if existing_has_pair and new_has_pair:
106
- if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
107
- return True
108
- return False
92
+ return _canonical_looks_contradictory(existing_text, new_text)
109
93
 
110
94
 
111
95
  def _find_conflicting_active_learning(conn, *, category: str, title: str, content: str,
@@ -286,7 +270,8 @@ def score_learning_quality(row: dict, conn=None) -> dict:
286
270
 
287
271
  def handle_learning_add(category: str, title: str, content: str, reasoning: str = '',
288
272
  prevention: str = '', applies_to: str = '', review_days: int = 30,
289
- priority: str = 'medium', supersedes_id: int = 0) -> str:
273
+ priority: str = 'medium', supersedes_id: int = 0,
274
+ source_authority: str = 'explicit_instruction') -> str:
290
275
  """Add a new learning entry to the specified category.
291
276
 
292
277
  Args:
@@ -304,15 +289,55 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
304
289
  category = category.lower().strip()
305
290
  if not category:
306
291
  return "ERROR: Category cannot be empty."
307
- # Dedup guard: block exact title duplicates in same category
308
292
  conn = get_db()
309
- existing = conn.execute(
310
- "SELECT id, title FROM learnings WHERE LOWER(title) = LOWER(?) AND category = ? AND status = 'active'",
311
- (title.strip(), category)
312
- ).fetchone()
313
- if existing:
314
- _resolve_pending_correction_learning(int(existing["id"]))
315
- return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
293
+ resolution = resolve_learning_candidate(
294
+ category=category,
295
+ title=title,
296
+ content=content,
297
+ reasoning=reasoning,
298
+ prevention=prevention,
299
+ applies_to=applies_to,
300
+ priority=priority,
301
+ supersedes_id=supersedes_id,
302
+ source_authority=source_authority,
303
+ conn=conn,
304
+ )
305
+ if resolution["action"] == "reject":
306
+ return f"ERROR: Learning candidate rejected: {resolution['reason']}."
307
+ if resolution["action"] == "merge":
308
+ existing_id = int(resolution.get("target_id") or 0)
309
+ existing = conn.execute("SELECT id, title, weight FROM learnings WHERE id = ?", (existing_id,)).fetchone()
310
+ if existing:
311
+ if resolution.get("reason") == "exact_title_duplicate":
312
+ _resolve_pending_correction_learning(existing_id)
313
+ return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
314
+ old_weight = float(existing["weight"] or 0.0)
315
+ new_weight = min(1.0, old_weight + 0.1)
316
+ conn.execute(
317
+ "UPDATE learnings SET weight = ?, updated_at = ? WHERE id = ?",
318
+ (new_weight, now_epoch(), existing_id),
319
+ )
320
+ conn.commit()
321
+ _resolve_pending_correction_learning(existing_id)
322
+ return (
323
+ f"Learning #{existing_id} resolved as merge ({resolution['reason']}, similarity "
324
+ f"{float(resolution.get('similarity') or 0):.2f}). No duplicate created. "
325
+ f"Weight bumped {old_weight:.2f} -> {new_weight:.2f}. Use nexo_learning_update(id={existing_id}) "
326
+ "to refine the canonical text."
327
+ )
328
+ if resolution["action"] == "conflict_review":
329
+ conflicting = {
330
+ "id": resolution.get("target_id"),
331
+ "title": resolution.get("target_title"),
332
+ "applies_to": applies_to,
333
+ }
334
+ return (
335
+ f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
336
+ f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
337
+ f"Supersede or update the existing canonical rule instead of creating two active file rules."
338
+ )
339
+ if resolution["action"] == "supersede":
340
+ supersedes_id = int(resolution.get("target_id") or supersedes_id or 0)
316
341
 
317
342
  # ── R05 (Fase 2 Protocol Enforcer): auto-merge on high Jaccard similarity ──
318
343
  # When a near-duplicate active learning exists (Jaccard >= R05 threshold),
@@ -356,19 +381,6 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
356
381
  f"→ {new_weight:.2f}. Use nexo_learning_update(id={existing_id}) if you need to "
357
382
  "refine the canonical text."
358
383
  )
359
- conflicting = _find_conflicting_active_learning(
360
- conn,
361
- category=category,
362
- title=title,
363
- content=content,
364
- applies_to=applies_to,
365
- )
366
- if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
367
- return (
368
- f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
369
- f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
370
- f"Supersede or update the existing canonical rule instead of creating two active file rules."
371
- )
372
384
  result = create_learning(
373
385
  category, title, content, reasoning=reasoning, supersedes_id=(int(supersedes_id) if supersedes_id else None)
374
386
  )
@@ -508,6 +520,25 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
508
520
  return f"Learning #{result['id']} added in {category}: {title}{meta_str} ✓verified{repetition_msg}{retro_meta_msg}{correction_msg}"
509
521
 
510
522
 
523
+ def handle_learning_resolve_candidate(category: str, title: str, content: str, reasoning: str = '',
524
+ prevention: str = '', applies_to: str = '',
525
+ priority: str = 'medium', supersedes_id: int = 0,
526
+ source_authority: str = 'inference') -> str:
527
+ """Dry-run the canonical learning resolver without mutating state."""
528
+ result = resolve_learning_candidate(
529
+ category=category,
530
+ title=title,
531
+ content=content,
532
+ reasoning=reasoning,
533
+ prevention=prevention,
534
+ applies_to=applies_to,
535
+ priority=priority,
536
+ supersedes_id=supersedes_id,
537
+ source_authority=source_authority,
538
+ )
539
+ return json.dumps(result, ensure_ascii=False, indent=2)
540
+
541
+
511
542
  def handle_learning_search(query: str, category: str = '') -> str:
512
543
  """Search learnings by query string, optionally filtered by category."""
513
544
  results = search_learnings(query, category if category else None)
@@ -173,8 +173,8 @@ def handle_memory_timeline(
173
173
  result = memory_timeline(query, project_hint=project_hint, time_range=time_range, limit=limit)
174
174
  candidates = result.get("candidates") or []
175
175
  if not candidates:
176
- return "No hay eventos suficientes para construir timeline."
177
- lines = [f"MEMORY TIMELINE ({len(candidates)}) — {query or time_range or '(sin query)'}"]
176
+ return "There are not enough events to build a timeline."
177
+ lines = [f"MEMORY TIMELINE ({len(candidates)}) — {query or time_range or '(no query)'}"]
178
178
  for item in candidates:
179
179
  refs = item.get("evidence_refs") or []
180
180
  refs_note = f" refs={', '.join(refs[:3])}" if refs else ""
@@ -1334,10 +1334,21 @@ def handle_context_packet(area: str, files: str = "") -> str:
1334
1334
  parts.append("")
1335
1335
 
1336
1336
  # 3. Active followups for this area
1337
- followups = conn.execute(
1338
- "SELECT id, description, date, verification FROM followups WHERE status = 'PENDING' AND (description LIKE ? OR verification LIKE ?) ORDER BY date ASC LIMIT 10",
1337
+ from db import followup_lifecycle_lane, normalize_followup_status
1338
+
1339
+ followup_rows = conn.execute(
1340
+ "SELECT id, description, date, verification, status, owner FROM followups "
1341
+ "WHERE (description LIKE ? OR verification LIKE ?) ORDER BY date ASC LIMIT 50",
1339
1342
  (f"%{area}%", f"%{area}%")
1340
1343
  ).fetchall()
1344
+ followups = []
1345
+ for row in followup_rows:
1346
+ item = dict(row)
1347
+ item["status"] = normalize_followup_status(item.get("status"))
1348
+ if followup_lifecycle_lane(item) == "active":
1349
+ followups.append(item)
1350
+ if len(followups) >= 10:
1351
+ break
1341
1352
  if followups:
1342
1353
  parts.append("## ACTIVE FOLLOWUPS")
1343
1354
  for f in followups:
@@ -1479,11 +1490,18 @@ def handle_smart_startup_query() -> str:
1479
1490
  sent_email_block = ""
1480
1491
 
1481
1492
  # 1. Pending followups (what NEXO needs to do)
1482
- followups = conn.execute(
1483
- "SELECT description FROM followups WHERE status = 'PENDING' ORDER BY date ASC LIMIT 5"
1484
- ).fetchall()
1485
- for f in followups:
1486
- query_parts.append(f['description'][:100])
1493
+ try:
1494
+ from db import followup_lifecycle_snapshot
1495
+
1496
+ active_followups = (followup_lifecycle_snapshot(limit=500).get("lanes") or {}).get("active", [])[:5]
1497
+ for f in active_followups:
1498
+ query_parts.append(str(f.get("description") or "")[:100])
1499
+ except Exception:
1500
+ followups = conn.execute(
1501
+ "SELECT description FROM followups WHERE status = 'PENDING' ORDER BY date ASC LIMIT 5"
1502
+ ).fetchall()
1503
+ for f in followups:
1504
+ query_parts.append(f['description'][:100])
1487
1505
 
1488
1506
  # 2. Due reminders (what the user needs to know)
1489
1507
  reminders = conn.execute(