nexo-brain 7.23.13 → 7.25.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 (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -11
  3. package/bin/nexo-brain.js +42 -235
  4. package/package.json +1 -1
  5. package/src/auto_update.py +30 -0
  6. package/src/automation_supervisor.py +1 -1
  7. package/src/cli.py +255 -9
  8. package/src/cognitive_control_observatory.py +224 -0
  9. package/src/crons/manifest.json +13 -0
  10. package/src/dashboard/app.py +26 -9
  11. package/src/db/__init__.py +2 -0
  12. package/src/db/_fts.py +38 -8
  13. package/src/db/_learnings.py +1 -1
  14. package/src/db/_memory_v2.py +107 -1
  15. package/src/db/_protocol.py +2 -2
  16. package/src/db/_reminders.py +132 -4
  17. package/src/db/_schema.py +48 -2
  18. package/src/doctor/providers/runtime.py +69 -0
  19. package/src/events_bus.py +4 -5
  20. package/src/learning_resolver.py +419 -0
  21. package/src/lifecycle_events.py +9 -9
  22. package/src/local_context/api.py +67 -5
  23. package/src/local_context/usage_events.py +24 -0
  24. package/src/memory_fabric.py +536 -0
  25. package/src/memory_observation_processor.py +28 -0
  26. package/src/memory_retrieval.py +5 -5
  27. package/src/operator_language.py +2 -0
  28. package/src/plugins/backup.py +1 -1
  29. package/src/plugins/cortex.py +21 -21
  30. package/src/plugins/episodic_memory.py +11 -11
  31. package/src/plugins/goal_engine.py +3 -3
  32. package/src/plugins/personal_scripts.py +75 -0
  33. package/src/plugins/protocol.py +10 -1
  34. package/src/pre_answer_router.py +120 -3
  35. package/src/r_catalog.py +4 -5
  36. package/src/saved_not_used_audit.py +31 -31
  37. package/src/script_registry.py +444 -1
  38. package/src/scripts/deep-sleep/apply_findings.py +79 -17
  39. package/src/scripts/nexo-backup.sh +30 -0
  40. package/src/scripts/nexo-daily-self-audit.py +46 -13
  41. package/src/scripts/nexo-email-migrate-config.py +2 -2
  42. package/src/scripts/nexo-email-monitor.py +19 -19
  43. package/src/scripts/nexo-followup-hygiene.py +40 -8
  44. package/src/scripts/nexo-followup-runner.py +31 -31
  45. package/src/scripts/nexo-inbox-hook.sh +1 -1
  46. package/src/scripts/nexo-learning-validator.py +24 -3
  47. package/src/scripts/nexo-memory-fabric.py +45 -0
  48. package/src/server.py +73 -1
  49. package/src/system_catalog.py +31 -31
  50. package/src/tools_learnings.py +96 -65
  51. package/src/tools_memory_v2.py +2 -2
  52. package/src/tools_sessions.py +25 -7
  53. package/src/tools_transcripts.py +50 -8
  54. package/src/transcript_index.py +105 -2
  55. package/src/transcript_utils.py +65 -13
  56. package/templates/core-prompts/postmortem-consolidator.md +3 -3
  57. package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
  58. package/templates/core-prompts/server-mcp-instructions.md +6 -6
  59. package/tool-enforcement-map.json +143 -13
@@ -578,44 +578,44 @@ def _score_alternative(
578
578
 
579
579
  if direct_hits:
580
580
  impact += min(1.6, direct_hits * 0.4)
581
- reasons.append("apunta directo al objetivo")
581
+ reasons.append("points directly at the goal")
582
582
  if safe_hits:
583
583
  success += min(1.8, safe_hits * 0.45)
584
584
  risk = max(1.0, risk - min(1.1, safe_hits * 0.35))
585
- reasons.append("incluye verificación o despliegue seguro")
585
+ reasons.append("includes verification or safe deployment")
586
586
  if not safe_hits and task_type in {"edit", "execute"}:
587
587
  risk += 1.2
588
- reasons.append("no explicita verificación")
588
+ reasons.append("does not make verification explicit")
589
589
  if risk_hits:
590
590
  risk += min(2.8, risk_hits * 0.7)
591
- reasons.append("contiene señales de alto riesgo")
591
+ reasons.append("contains high-risk signals")
592
592
 
593
593
  if focus == "impact" and direct_hits:
594
594
  impact += 0.45
595
595
  risk = max(1.0, risk - 0.35)
596
- reasons.append("el perfil activo prioriza impacto")
596
+ reasons.append("the active profile prioritizes impact")
597
597
  elif focus == "impact":
598
598
  impact = max(1.0, impact - 0.35)
599
- reasons.append("el perfil activo penaliza opciones de bajo empuje")
599
+ reasons.append("the active profile penalizes low-momentum options")
600
600
  elif focus == "success" and safe_hits:
601
601
  success += 0.45
602
- reasons.append("el perfil activo prioriza exito verificable")
602
+ reasons.append("the active profile prioritizes verifiable success")
603
603
  elif focus == "risk":
604
604
  if safe_hits:
605
605
  risk = max(1.0, risk - 0.4)
606
606
  if risk_hits:
607
607
  risk += 0.8
608
- reasons.append("el perfil activo penaliza riesgo")
608
+ reasons.append("the active profile penalizes risk")
609
609
  elif focus == "somatic":
610
- reasons.append("el perfil activo da peso a la huella somática")
610
+ reasons.append("the active profile weights the somatic footprint")
611
611
 
612
612
  history = _history_signal(lowered, area=area, goal=goal)
613
613
  success += history["positive"]
614
614
  risk += history["negative"]
615
615
  if history["positive"]:
616
- reasons.append("histórico parecido favorable")
616
+ reasons.append("similar history is favorable")
617
617
  if history["negative"]:
618
- reasons.append("histórico parecido conflictivo")
618
+ reasons.append("similar history is conflicting")
619
619
 
620
620
  historical = _historical_outcome_signal(
621
621
  alternative.get("name", ""),
@@ -628,15 +628,15 @@ def _score_alternative(
628
628
  risk += historical["risk_adjustment"]
629
629
  if historical["success_adjustment"] > 0:
630
630
  reasons.append(
631
- f"histórico resuelto favorable ({historical['met']}/{historical['resolved_outcomes']} met)"
631
+ f"resolved history is favorable ({historical['met']}/{historical['resolved_outcomes']} met)"
632
632
  )
633
633
  elif historical["success_adjustment"] < 0:
634
634
  reasons.append(
635
- f"histórico resuelto flojo ({historical['missed']}/{historical['resolved_outcomes']} missed)"
635
+ f"resolved history is weak ({historical['missed']}/{historical['resolved_outcomes']} missed)"
636
636
  )
637
637
  elif historical["resolved_outcomes"] > 0:
638
638
  reasons.append(
639
- f"histórico insuficiente aún ({historical['resolved_outcomes']}/{historical['threshold']} outcomes)"
639
+ f"history is still insufficient ({historical['resolved_outcomes']}/{historical['threshold']} outcomes)"
640
640
  )
641
641
 
642
642
  pattern_learning = _pattern_learning_signal(
@@ -649,9 +649,9 @@ def _score_alternative(
649
649
  success += pattern_learning["success_adjustment"]
650
650
  risk += pattern_learning["risk_adjustment"]
651
651
  if pattern_learning["mode"] == "prefer":
652
- reasons.append("regla estructurada capturada favorece esta estrategia")
652
+ reasons.append("captured structured rule favors this strategy")
653
653
  elif pattern_learning["mode"] == "avoid":
654
- reasons.append("regla estructurada capturada penaliza esta estrategia")
654
+ reasons.append("captured structured rule penalizes this strategy")
655
655
 
656
656
  constraint_penalty, constraint_reasons = _constraint_penalty(lowered, constraints)
657
657
  if constraint_penalty:
@@ -722,19 +722,19 @@ def _log_cortex_activation(goal: str, task_type: str, result: dict):
722
722
 
723
723
 
724
724
  def _format_decision_summary(recommended: dict, alternatives_scored: list[dict]) -> str:
725
- notes = ", ".join(recommended.get("notes") or []) or "balance general más sólido"
725
+ notes = ", ".join(recommended.get("notes") or []) or "strongest overall balance"
726
726
  historical = recommended.get("historical_signal") or {}
727
727
  second_gap = 0.0
728
728
  if len(alternatives_scored) > 1:
729
729
  second_gap = recommended["total_score"] - alternatives_scored[1]["total_score"]
730
730
  if historical.get("active"):
731
731
  notes = (
732
- f"{notes}; histórico resuelto {historical.get('met', 0)}/"
733
- f"{historical.get('resolved_outcomes', 0)} favorable en contexto comparable"
732
+ f"{notes}; resolved history {historical.get('met', 0)}/"
733
+ f"{historical.get('resolved_outcomes', 0)} favorable in comparable context"
734
734
  )
735
735
  if second_gap > 0.2:
736
- return f"Recomendada por margen claro ({second_gap:.2f}) y porque {notes}."
737
- return f"Recomendada por el mejor balance entre impacto, éxito, riesgo y huella somática; {notes}."
736
+ return f"Recommended by a clear margin ({second_gap:.2f}) and because {notes}."
737
+ return f"Recommended for the best balance between impact, success, risk, and somatic footprint; {notes}."
738
738
 
739
739
 
740
740
  def _parse_json_object_response(raw: str) -> dict:
@@ -256,7 +256,7 @@ def handle_session_diary_write(decisions: str = '', summary: str = '',
256
256
  self_critique: str = '',
257
257
  source: str = 'claude',
258
258
  payload_json: str = '') -> str:
259
- """Write session diary entry at end of session. OBLIGATORIO antes de cerrar.
259
+ """Write a session diary entry at end of session. Mandatory before closing.
260
260
 
261
261
  Args:
262
262
  decisions: What was decided and why (JSON array or structured text)
@@ -344,22 +344,22 @@ def handle_session_diary_write(decisions: str = '', summary: str = '',
344
344
  if repo_orphan_changes > 0:
345
345
  if recent_repo_orphan_changes > 0 and recent_repo_orphan_changes != repo_orphan_changes:
346
346
  warnings.append(
347
- f"{_recent_change_phrase(recent_repo_orphan_changes)} de repo sin commit_ref "
347
+ f"{_recent_change_phrase(recent_repo_orphan_changes)} repo changes without commit_ref "
348
348
  f"({_format_change_count(repo_orphan_changes)} de repo total)"
349
349
  )
350
350
  elif recent_repo_orphan_changes > 0:
351
351
  warnings.append(
352
- f"{_recent_change_phrase(recent_repo_orphan_changes)} de repo sin commit_ref"
352
+ f"{_recent_change_phrase(recent_repo_orphan_changes)} repo changes without commit_ref"
353
353
  )
354
354
  else:
355
355
  warnings.append(
356
- f"{_format_change_count(repo_orphan_changes)} históricos de repo sin commit_ref"
356
+ f"{_format_change_count(repo_orphan_changes)} historical repo changes without commit_ref"
357
357
  )
358
358
  orphan_decisions = conn.execute(
359
359
  "SELECT COUNT(*) FROM decisions WHERE (outcome IS NULL OR outcome = '') AND created_at < datetime('now', '-7 days')"
360
360
  ).fetchone()[0]
361
361
  if orphan_decisions > 0:
362
- warnings.append(f"{orphan_decisions} decisions >7d sin outcome")
362
+ warnings.append(f"{orphan_decisions} decisions >7d without outcome")
363
363
  if warnings:
364
364
  msg += "\n⚠ EPISODIC GAPS: " + " | ".join(warnings) + " — resolve before closing session."
365
365
 
@@ -423,13 +423,13 @@ def _handle_session_diary_read_inner(session_id: str = '', last_n: int = 3, last
423
423
  if d.get('decisions'):
424
424
  lines.append(f" Decisions: {d['decisions'][:200]}")
425
425
  if d.get('discarded'):
426
- lines.append(f" Descartado: {d['discarded'][:150]}")
426
+ lines.append(f" Discarded: {d['discarded'][:150]}")
427
427
  if d.get('pending'):
428
428
  lines.append(f" Pending: {d['pending'][:150]}")
429
429
  if d.get('context_next'):
430
430
  lines.append(f" For next session: {d['context_next'][:200]}")
431
431
  if d.get('mental_state'):
432
- lines.append(f" Estado mental: {d['mental_state'][:300]}")
432
+ lines.append(f" Mental state: {d['mental_state'][:300]}")
433
433
  if d.get('user_signals'):
434
434
  lines.append(f" User signals: {d['user_signals'][:300]}")
435
435
  return "\n".join(lines)
@@ -477,7 +477,7 @@ def handle_change_log(files: str, what_changed: str, why: str,
477
477
  )
478
478
  else:
479
479
  msg += (
480
- f"\n⚠ NO COMMIT GIT. If this was a local/server-side change, link a marker "
480
+ f"\n⚠ NO GIT COMMIT. If this was a local/server-side change, link a marker "
481
481
  f"with nexo_change_commit({change_id}, 'server-direct') or "
482
482
  f"'local-uncommitted'."
483
483
  )
@@ -507,9 +507,9 @@ def handle_change_search(query: str = '', files: str = '', days: int = 30) -> st
507
507
  if c.get('triggered_by'):
508
508
  lines.append(f" Trigger: {c['triggered_by'][:80]}")
509
509
  if c.get('affects'):
510
- lines.append(f" Afecta: {c['affects'][:80]}")
510
+ lines.append(f" Affects: {c['affects'][:80]}")
511
511
  if c.get('risks'):
512
- lines.append(f" Riesgos: {c['risks'][:80]}")
512
+ lines.append(f" Risks: {c['risks'][:80]}")
513
513
  return "\n".join(lines)
514
514
 
515
515
 
@@ -644,7 +644,7 @@ def handle_diary_archive_search(
644
644
  if r.get('decisions'):
645
645
  lines.append(f" Decisions: {r['decisions'][:150]}")
646
646
  if r.get('mental_state'):
647
- lines.append(f" Estado: {r['mental_state'][:100]}")
647
+ lines.append(f" State: {r['mental_state'][:100]}")
648
648
  return "\n".join(lines)
649
649
 
650
650
 
@@ -107,11 +107,11 @@ def handle_goal_engine_status() -> str:
107
107
  }
108
108
  next_gap = []
109
109
  if not readiness["has_outcome_history"]:
110
- next_gap.append("Necesita acumular outcomes reales antes de optimizar por historico.")
110
+ next_gap.append("Needs real outcomes before optimizing from history.")
111
111
  if not readiness["has_cortex_history"]:
112
- next_gap.append("Necesita acumular evaluaciones reales del cortex antes de medir Decision Cortex v2.")
112
+ next_gap.append("Needs real cortex evaluations before measuring Decision Cortex v2.")
113
113
  if not readiness["has_linked_decisions"]:
114
- next_gap.append("Necesita decisiones de impacto enlazadas a outcome para cerrar el loop.")
114
+ next_gap.append("Needs impact decisions linked to outcomes to close the loop.")
115
115
 
116
116
  return json.dumps(
117
117
  {
@@ -6,13 +6,19 @@ from collections import Counter
6
6
  from db import init_db, list_personal_scripts, list_personal_script_schedules
7
7
  from plugins.schedule import handle_schedule_add
8
8
  from script_registry import (
9
+ archive_agent,
9
10
  classify_scripts_dir,
11
+ create_agent_script,
10
12
  create_script,
11
13
  ensure_personal_schedules,
14
+ get_agent_status,
12
15
  get_automation_status,
16
+ list_agents,
13
17
  list_operator_automations,
14
18
  reconcile_personal_scripts,
15
19
  remove_personal_script,
20
+ set_agent_enabled,
21
+ set_agent_schedule,
16
22
  set_automation_enabled,
17
23
  set_automation_instructions,
18
24
  set_automation_schedule,
@@ -168,6 +174,61 @@ def handle_automations_list(include_all: bool = False) -> str:
168
174
  return json.dumps({"ok": True, "automations": list_operator_automations(include_all=include_all)}, ensure_ascii=False)
169
175
 
170
176
 
177
+ def handle_agents_list(include_archived: bool = False) -> str:
178
+ init_db()
179
+ return json.dumps({"ok": True, "agents": list_agents(include_archived=include_archived)}, ensure_ascii=False)
180
+
181
+
182
+ def handle_agent_status(name: str) -> str:
183
+ init_db()
184
+ return json.dumps(get_agent_status(name), ensure_ascii=False)
185
+
186
+
187
+ def handle_agent_create(name: str, description: str = "", runtime: str = "python") -> str:
188
+ init_db()
189
+ try:
190
+ return json.dumps(create_agent_script(name, description=description, runtime=runtime), ensure_ascii=False)
191
+ except (FileExistsError, ValueError) as exc:
192
+ return json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)
193
+
194
+
195
+ def handle_agent_enable(name: str) -> str:
196
+ init_db()
197
+ return json.dumps(set_agent_enabled(name, True), ensure_ascii=False)
198
+
199
+
200
+ def handle_agent_disable(name: str) -> str:
201
+ init_db()
202
+ return json.dumps(set_agent_enabled(name, False), ensure_ascii=False)
203
+
204
+
205
+ def handle_agent_archive(name: str, restore: bool = False) -> str:
206
+ init_db()
207
+ return json.dumps(archive_agent(name, archived=not bool(restore)), ensure_ascii=False)
208
+
209
+
210
+ def handle_agent_schedule(
211
+ name: str,
212
+ every_seconds: int = 0,
213
+ daily_at: str = "",
214
+ clear: bool = False,
215
+ ) -> str:
216
+ init_db()
217
+ try:
218
+ interval_seconds = int(every_seconds or 0) or None
219
+ except (TypeError, ValueError):
220
+ return json.dumps({"ok": False, "error": f"Invalid every_seconds: {every_seconds}"}, ensure_ascii=False)
221
+ return json.dumps(
222
+ set_agent_schedule(
223
+ name,
224
+ interval_seconds=interval_seconds,
225
+ daily_at=str(daily_at or "").strip() or None,
226
+ clear=bool(clear),
227
+ ),
228
+ ensure_ascii=False,
229
+ )
230
+
231
+
171
232
  def handle_automation_status(name: str) -> str:
172
233
  init_db()
173
234
  return json.dumps(get_automation_status(name), ensure_ascii=False)
@@ -273,6 +334,20 @@ TOOLS = [
273
334
  "Set or clear operator-side extra instructions for one automation without editing the core prompt."),
274
335
  (handle_automation_schedule, "nexo_automation_schedule",
275
336
  "Set or clear the cadence override for one operator-facing automation."),
337
+ (handle_agents_list, "nexo_agents_list",
338
+ "List personal scripts marked as NEXO agents for the Desktop Home panel."),
339
+ (handle_agent_status, "nexo_agent_status",
340
+ "Read the composed runtime status for one personal-script-backed agent."),
341
+ (handle_agent_create, "nexo_agent_create",
342
+ "Create a personal script scaffold already marked as a NEXO agent."),
343
+ (handle_agent_enable, "nexo_agent_enable",
344
+ "Enable one personal-script-backed agent."),
345
+ (handle_agent_disable, "nexo_agent_disable",
346
+ "Disable one personal-script-backed agent without deleting its schedule."),
347
+ (handle_agent_archive, "nexo_agent_archive",
348
+ "Archive or restore one personal-script-backed agent without deleting its file."),
349
+ (handle_agent_schedule, "nexo_agent_schedule",
350
+ "Set or clear the cadence for one personal-script-backed agent."),
276
351
  (handle_core_schedules_list, "nexo_core_schedules_list",
277
352
  "List structural core crons whose cadence can be tuned without disabling them."),
278
353
  (handle_core_schedule_status, "nexo_core_schedule_status",
@@ -325,6 +325,7 @@ HIGH_STAKES_OVERRIDE_FALSE = {
325
325
  "dry-run",
326
326
  "dryrun",
327
327
  }
328
+ TASK_OPEN_LOCAL_CONTEXT_TRUE_VALUES = {"1", "true", "yes", "y", "on", "force"}
328
329
 
329
330
  _INTERNAL_AUDIT_AREAS = {
330
331
  "guardian",
@@ -450,6 +451,14 @@ def _parse_bool(value) -> bool:
450
451
  return bool(value)
451
452
 
452
453
 
454
+ def _task_open_local_context_enabled() -> bool:
455
+ """Keep heavy local-context routing off the task_open critical path by default."""
456
+ return (
457
+ os.environ.get("NEXO_TASK_OPEN_LOCAL_CONTEXT", "").strip().lower()
458
+ in TASK_OPEN_LOCAL_CONTEXT_TRUE_VALUES
459
+ )
460
+
461
+
453
462
  def _parse_int_list(value) -> list[int]:
454
463
  items = _parse_list(value)
455
464
  parsed: list[int] = []
@@ -1326,7 +1335,7 @@ def handle_task_open(
1326
1335
  response_contract["next_action"] = next_action
1327
1336
 
1328
1337
  recent_excerpt = format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else ""
1329
- if append_local_context_evidence is not None:
1338
+ if append_local_context_evidence is not None and _task_open_local_context_enabled():
1330
1339
  recent_excerpt = append_local_context_evidence(
1331
1340
  recent_excerpt,
1332
1341
  " | ".join(part for part in [clean_goal, context_hint.strip()] if part),
@@ -440,6 +440,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
440
440
  fallback=(
441
441
  SourceStep("transcripts", phase="fallback", timeout_ms=700),
442
442
  SourceStep("memory", phase="fallback", timeout_ms=400),
443
+ SourceStep("local_context", phase="fallback", timeout_ms=700, max_chars=700),
443
444
  ),
444
445
  ),
445
446
  "file_location": SourcePlan(
@@ -1099,10 +1100,11 @@ def _source_diary(request: SourceRequest) -> SourceResult:
1099
1100
 
1100
1101
  def _source_transcripts(request: SourceRequest) -> SourceResult:
1101
1102
  try:
1102
- from transcript_index import index_recent_transcripts, search_transcript_index
1103
+ from transcript_index import ensure_transcript_index, search_transcript_index
1104
+ from transcript_utils import MAX_TRANSCRIPT_HOURS
1103
1105
 
1104
- index_recent_transcripts(hours=72, limit=120, min_user_messages=1)
1105
- indexed_rows = search_transcript_index(request.query, hours=72, limit=4)
1106
+ ensure_transcript_index(hours=MAX_TRANSCRIPT_HOURS, limit=1000, min_user_messages=1)
1107
+ indexed_rows = search_transcript_index(request.query, hours=MAX_TRANSCRIPT_HOURS, limit=4)
1106
1108
  if indexed_rows:
1107
1109
  indexed_result = _rows_result(
1108
1110
  "transcript_index",
@@ -1174,6 +1176,25 @@ def _source_project_atlas(request: SourceRequest) -> SourceResult:
1174
1176
  def _source_local_context(request: SourceRequest) -> SourceResult:
1175
1177
  from local_context import api as local_context_api
1176
1178
 
1179
+ mode = _local_context_pre_answer_mode()
1180
+ if mode == "off":
1181
+ _record_local_context_skip(request, mode=mode, reason="disabled")
1182
+ return SourceResult(
1183
+ source="local_context",
1184
+ ok=True,
1185
+ skipped=True,
1186
+ aborted_reason="disabled",
1187
+ )
1188
+ if not _local_context_query_worthwhile(request):
1189
+ _record_local_context_skip(request, mode=mode, reason="adaptive_skip")
1190
+ return SourceResult(
1191
+ source="local_context",
1192
+ ok=True,
1193
+ skipped=True,
1194
+ aborted_reason="adaptive_skip",
1195
+ )
1196
+
1197
+ started = time.monotonic()
1177
1198
  payload = local_context_api.context_router(
1178
1199
  request.query,
1179
1200
  intent=request.intent,
@@ -1181,6 +1202,20 @@ def _source_local_context(request: SourceRequest) -> SourceResult:
1181
1202
  current_context=request.current_context,
1182
1203
  max_chars=request.max_chars,
1183
1204
  )
1205
+ elapsed_ms = (time.monotonic() - started) * 1000
1206
+ _record_local_context_pre_answer_usage(
1207
+ request,
1208
+ payload,
1209
+ mode=mode,
1210
+ elapsed_ms=elapsed_ms,
1211
+ )
1212
+ if mode == "shadow":
1213
+ return SourceResult(
1214
+ source="local_context",
1215
+ ok=True,
1216
+ skipped=True,
1217
+ aborted_reason="shadow_no_inject",
1218
+ )
1184
1219
  if not payload.get("should_inject"):
1185
1220
  return SourceResult(source="local_context", result_count=0)
1186
1221
  return SourceResult(
@@ -1191,6 +1226,88 @@ def _source_local_context(request: SourceRequest) -> SourceResult:
1191
1226
  )
1192
1227
 
1193
1228
 
1229
+ def _local_context_pre_answer_mode() -> str:
1230
+ value = (
1231
+ os.environ.get("NEXO_PRE_ANSWER_LOCAL_CONTEXT_MODE")
1232
+ or os.environ.get("NEXO_LOCAL_CONTEXT_PRE_ANSWER_MODE")
1233
+ or "inject"
1234
+ )
1235
+ clean = str(value or "").strip().lower()
1236
+ if clean in {"0", "false", "no", "off", "disabled"}:
1237
+ return "off"
1238
+ if clean in {"shadow", "observe", "observability", "audit"}:
1239
+ return "shadow"
1240
+ return "inject"
1241
+
1242
+
1243
+ def _local_context_query_worthwhile(request: SourceRequest) -> bool:
1244
+ if request.intent == "file_location":
1245
+ return True
1246
+ if request.files.strip() or request.area.strip():
1247
+ return True
1248
+ normalized = _normalize(f"{request.query}\n{request.current_context}")
1249
+ if _PATHISH_RE.search(normalized):
1250
+ return True
1251
+ tokens = _plain_tokens(normalized)
1252
+ concept_score = max(
1253
+ _feature_score(tokens, _FEATURE_LEXICON[field])
1254
+ for field in ("existing_ref", "location", "memory", "modify", "past_work")
1255
+ )
1256
+ return concept_score >= 0.18
1257
+
1258
+
1259
+ def _record_local_context_skip(request: SourceRequest, *, mode: str, reason: str) -> None:
1260
+ try:
1261
+ from local_context import usage_events
1262
+
1263
+ usage_events.record_usage_event(
1264
+ query=request.query,
1265
+ client="pre_answer_router",
1266
+ tool="local_context",
1267
+ source="local_context",
1268
+ route_stage=f"pre_answer:{mode}",
1269
+ intent=request.intent,
1270
+ result_count=0,
1271
+ should_inject=False,
1272
+ aborted_reason=reason,
1273
+ used_before_response=True,
1274
+ metadata={
1275
+ "adaptive": reason == "adaptive_skip",
1276
+ "current_context_present": bool(request.current_context),
1277
+ },
1278
+ )
1279
+ except Exception:
1280
+ return
1281
+
1282
+
1283
+ def _record_local_context_pre_answer_usage(
1284
+ request: SourceRequest,
1285
+ payload: dict[str, Any],
1286
+ *,
1287
+ mode: str,
1288
+ elapsed_ms: float,
1289
+ ) -> None:
1290
+ try:
1291
+ from local_context import usage_events
1292
+
1293
+ usage_payload = dict(payload)
1294
+ usage_payload["intent"] = request.intent
1295
+ usage_payload["should_inject"] = bool(payload.get("should_inject")) and mode == "inject"
1296
+ usage_events.record_router_usage(
1297
+ request.query,
1298
+ usage_payload,
1299
+ client="pre_answer_router",
1300
+ tool="local_context",
1301
+ route_stage=f"pre_answer:{mode}",
1302
+ intent=request.intent,
1303
+ elapsed_ms=int(max(0.0, elapsed_ms)),
1304
+ deadline_ms=0,
1305
+ used_before_response=True,
1306
+ )
1307
+ except Exception:
1308
+ return
1309
+
1310
+
1194
1311
  def _source_filesystem(request: SourceRequest) -> SourceResult:
1195
1312
  root = Path.cwd()
1196
1313
  try:
package/src/r_catalog.py CHANGED
@@ -4,11 +4,10 @@ Pre-create discovery probe. Two trigger families:
4
4
 
5
5
  (a) `nexo_*_create` / `_open` / `_add` — the original MCP-tool path.
6
6
  (b) v7.7: `Edit` / `Write` writing into artefact-bearing paths
7
- (skills/, plugins/, scripts/, personal scripts). The checklist
8
- item "ampliar el ámbito del pre-probe de catálogo para cubrir
9
- no solo nexo_*_create/_open/_add, sino también writes de
10
- archivos que materializan skills/plugins/scripts/plantillas/
11
- artefactos aunque no hayan pasado por un tool MCP de 'create'".
7
+ (skills/, plugins/, scripts/, personal scripts). This expands the
8
+ catalog pre-probe beyond `nexo_*_create/_open/_add` to also cover
9
+ writes that materialise skills, plugins, scripts, templates, or
10
+ artefacts without going through a dedicated MCP `create` tool.
12
11
 
13
12
  Rationale: the Guardian should not re-teach what tools exist; the live
14
13
  catalog does that. But if the agent materialises a new skill / plugin /