nexo-brain 7.27.2 → 7.27.6

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.
@@ -61,6 +61,13 @@ R03_TRIVIAL_EVIDENCE_PATTERN = re.compile(
61
61
  r"terminado|arreglado|cerrado|solved|resuelto)\s*[\.!]*\s*$",
62
62
  re.IGNORECASE,
63
63
  )
64
+ P0_P1_FINDING_PATTERN = re.compile(
65
+ r"^\s*(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?(?:\*\*)?"
66
+ r"(P[01])(?:\*\*)?\s*(?:[:\-–—\])\)]|\b)",
67
+ re.IGNORECASE,
68
+ )
69
+ FOLLOWUP_REF_PATTERN = re.compile(r"\bNF-[A-Z0-9][A-Z0-9-]*\b", re.IGNORECASE)
70
+ ANALYZE_ARTIFACT_SUFFIXES = {".md", ".markdown", ".txt"}
64
71
 
65
72
 
66
73
  def _is_trivial_evidence(text: str) -> tuple[bool, str]:
@@ -85,6 +92,54 @@ def _is_trivial_evidence(text: str) -> tuple[bool, str]:
85
92
  return False, ""
86
93
 
87
94
 
95
+ def _existing_analyze_artifact_paths(refs: list[str]) -> list[Path]:
96
+ paths_found: list[Path] = []
97
+ seen: set[str] = set()
98
+ for ref in refs:
99
+ clean = str(ref or "").strip()
100
+ if not clean or clean.lower().startswith("followup_id"):
101
+ continue
102
+ if ":" in clean and not clean.startswith("/"):
103
+ prefix, value = clean.split(":", 1)
104
+ if prefix.strip().lower() in {"file", "path", "artifact", "report"}:
105
+ clean = value.strip()
106
+ candidate = Path(os.path.expanduser(clean))
107
+ if not candidate.is_file() or candidate.suffix.lower() not in ANALYZE_ARTIFACT_SUFFIXES:
108
+ continue
109
+ resolved = str(candidate.resolve())
110
+ if resolved in seen:
111
+ continue
112
+ seen.add(resolved)
113
+ paths_found.append(candidate)
114
+ return paths_found
115
+
116
+
117
+ def _count_p0_p1_findings(paths_found: list[Path]) -> tuple[int, list[dict]]:
118
+ total = 0
119
+ artifacts: list[dict] = []
120
+ for path in paths_found:
121
+ findings = 0
122
+ try:
123
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
124
+ for line in fh:
125
+ if P0_P1_FINDING_PATTERN.search(line):
126
+ findings += 1
127
+ except OSError:
128
+ continue
129
+ if findings:
130
+ total += findings
131
+ artifacts.append({"path": str(path), "findings": findings})
132
+ return total, artifacts
133
+
134
+
135
+ def _count_followup_refs(refs: list[str]) -> int:
136
+ seen: set[str] = set()
137
+ for ref in refs:
138
+ for match in FOLLOWUP_REF_PATTERN.findall(str(ref or "")):
139
+ seen.add(match.upper())
140
+ return len(seen)
141
+
142
+
88
143
  def _external_real_world_text(task: dict, *parts: str) -> str:
89
144
  fields = [
90
145
  task.get("goal", ""),
@@ -1493,6 +1548,7 @@ def handle_task_close(
1493
1548
  if extra_refs:
1494
1549
  refs_line = "Evidence refs: " + ", ".join(extra_refs)
1495
1550
  clean_evidence = f"{clean_evidence}\n{refs_line}".strip() if clean_evidence else refs_line
1551
+ all_evidence_refs = [*_parse_list(task.get("evidence_refs") or "[]"), *extra_refs]
1496
1552
  files_changed_list = _parse_list(files_changed)
1497
1553
  planned_files = _parse_list(task.get("files") or "[]")
1498
1554
  effective_files = files_changed_list or planned_files
@@ -1508,6 +1564,46 @@ def handle_task_close(
1508
1564
  high_stakes=bool(task.get("response_high_stakes")),
1509
1565
  )
1510
1566
 
1567
+ if (task.get("task_type") or "").strip() == "analyze" and clean_outcome == "done":
1568
+ artifact_paths = _existing_analyze_artifact_paths(all_evidence_refs)
1569
+ finding_count, finding_artifacts = _count_p0_p1_findings(artifact_paths)
1570
+ followup_ref_count = _count_followup_refs(all_evidence_refs)
1571
+ if finding_count > followup_ref_count:
1572
+ missing = finding_count - followup_ref_count
1573
+ debt = _ensure_open_debt(
1574
+ task["session_id"],
1575
+ task_id,
1576
+ "analyze_p0_p1_followups_missing",
1577
+ severity="error",
1578
+ evidence=(
1579
+ f"Analyze task produced {finding_count} P0/P1 finding(s) in report artifact(s) "
1580
+ f"but evidence_refs only contained {followup_ref_count} followup id(s); "
1581
+ f"{missing} actionable finding(s) would be left without durable followup. "
1582
+ f"Artifacts: {json.dumps(finding_artifacts, ensure_ascii=False)}"
1583
+ ),
1584
+ debts=debts_created,
1585
+ )
1586
+ return json.dumps(
1587
+ {
1588
+ "ok": False,
1589
+ "error": "Cannot close analyze task as 'done' while P0/P1 report findings lack followup refs.",
1590
+ "hint": (
1591
+ "Create one followup for each P0/P1 finding and pass those followup IDs in evidence_refs, "
1592
+ "then retry nexo_task_close."
1593
+ ),
1594
+ "task_id": task_id,
1595
+ "blocked_by": "analyze_p0_p1_followup_gate",
1596
+ "debt_id": debt.get("id"),
1597
+ "debt_type": "analyze_p0_p1_followups_missing",
1598
+ "findings": finding_count,
1599
+ "followup_refs": followup_ref_count,
1600
+ "missing_followups": missing,
1601
+ "artifacts": finding_artifacts,
1602
+ },
1603
+ ensure_ascii=False,
1604
+ indent=2,
1605
+ )
1606
+
1511
1607
  pending_corrections = list_session_correction_requirements(
1512
1608
  session_id=task["session_id"],
1513
1609
  status="open",
@@ -39,6 +39,7 @@ INJECTING_INTENTS = set(PRE_ANSWER_INTENTS) - {"general"}
39
39
  DEFAULT_BUDGET_MS = 2500
40
40
  DEFAULT_TOKEN_BUDGET = 2500
41
41
  MAX_SOURCE_WORKERS = int(os.environ.get("NEXO_PRE_ANSWER_SOURCE_WORKERS", "6") or "6")
42
+ PRE_ANSWER_SEMANTIC_DECISION_KIND = "pre_answer_intent"
42
43
 
43
44
  _WORD_RE = re.compile(r"[a-z0-9_./:@-]+")
44
45
  _PLAIN_WORD_RE = re.compile(r"[a-z0-9_]+")
@@ -46,6 +47,59 @@ _PATHISH_RE = re.compile(
46
47
  r"(?:(?:~|\.{1,2}|/)[\w./@+-]+|[\w.-]+\.(?:py|js|ts|tsx|jsx|md|json|db|sqlite|yml|yaml|txt|csv))"
47
48
  )
48
49
  _DATEISH_RE = re.compile(r"\b(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|\d{4}-\d{2}-\d{2})\b")
50
+ _COLD_CONTINUITY_STEMS: tuple[str, ...] = (
51
+ "promet",
52
+ "promise",
53
+ "commit",
54
+ "compromis",
55
+ "pendient",
56
+ "pending",
57
+ "followup",
58
+ "deadline",
59
+ "recordatorio",
60
+ "reminder",
61
+ "hice",
62
+ "hiciste",
63
+ "hicimos",
64
+ "hecho",
65
+ "toque",
66
+ "toqu",
67
+ "tocado",
68
+ "touched",
69
+ )
70
+ _COLD_COMMITMENT_STEMS: tuple[str, ...] = (
71
+ "promet",
72
+ "promise",
73
+ "commit",
74
+ "compromis",
75
+ "pendient",
76
+ "pending",
77
+ "followup",
78
+ "deadline",
79
+ "recordatorio",
80
+ "reminder",
81
+ )
82
+ _COLD_OPERATOR_TOKENS: frozenset[str] = frozenset(
83
+ {
84
+ "i",
85
+ "me",
86
+ "my",
87
+ "you",
88
+ "your",
89
+ "we",
90
+ "our",
91
+ "yo",
92
+ "mi",
93
+ "mis",
94
+ "me",
95
+ "tu",
96
+ "tus",
97
+ "nosotros",
98
+ "nuestro",
99
+ "nuestra",
100
+ "nero",
101
+ }
102
+ )
49
103
  _SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
50
104
  (
51
105
  re.compile(
@@ -297,7 +351,7 @@ _INTENT_FEATURE_WEIGHTS: dict[str, dict[str, float]] = {
297
351
  "modify_existing": {"modify": 1.30, "existing_ref": 0.70, "location": 0.20, "past_work": 0.20},
298
352
  "memory_question": {"memory": 1.40, "past_work": 0.20, "identity": 0.15},
299
353
  "identity_authorship": {"identity": 1.20, "past_work": 0.65},
300
- "schedule_commitment": {"schedule": 1.55, "past_work": 0.10},
354
+ "schedule_commitment": {"schedule": 1.55, "past_work": 0.10, "memory": 0.20},
301
355
  "runtime_diagnosis": {"runtime": 1.55, "location": 0.15},
302
356
  }
303
357
 
@@ -432,6 +486,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
432
486
  primary=(
433
487
  SourceStep("recent_context", timeout_ms=240),
434
488
  SourceStep("evidence_ledger", timeout_ms=260),
489
+ SourceStep("commitments", timeout_ms=180),
435
490
  SourceStep("protocol_tasks", timeout_ms=240),
436
491
  SourceStep("workflows", timeout_ms=260),
437
492
  SourceStep("change_log", timeout_ms=260),
@@ -470,6 +525,9 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
470
525
  "memory_question": SourcePlan(
471
526
  intent="memory_question",
472
527
  primary=(
528
+ SourceStep("commitments", timeout_ms=180),
529
+ SourceStep("reminders", timeout_ms=260),
530
+ SourceStep("followups", timeout_ms=260),
473
531
  SourceStep("diary", timeout_ms=280),
474
532
  SourceStep("evidence_ledger", timeout_ms=260),
475
533
  SourceStep("memory", timeout_ms=500),
@@ -485,6 +543,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
485
543
  primary=(
486
544
  SourceStep("recent_context", timeout_ms=240),
487
545
  SourceStep("evidence_ledger", timeout_ms=260),
546
+ SourceStep("commitments", timeout_ms=180),
488
547
  SourceStep("diary", timeout_ms=280),
489
548
  SourceStep("change_log", timeout_ms=300),
490
549
  SourceStep("transcripts", timeout_ms=700),
@@ -494,6 +553,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
494
553
  "schedule_commitment": SourcePlan(
495
554
  intent="schedule_commitment",
496
555
  primary=(
556
+ SourceStep("commitments", timeout_ms=180),
497
557
  SourceStep("reminders", timeout_ms=260),
498
558
  SourceStep("followups", timeout_ms=260),
499
559
  SourceStep("workflows", timeout_ms=280),
@@ -522,8 +582,81 @@ _EXECUTOR: concurrent.futures.ThreadPoolExecutor | None = None
522
582
 
523
583
 
524
584
  def classify_intent(query: str, *, current_context: str = "") -> IntentClassification:
525
- """Classify the turn using multilingual concept overlap, not phrase rules."""
585
+ """Classify the turn through the semantic router first.
586
+
587
+ The legacy concept-overlap scorer remains only as a degraded fallback for
588
+ installs where the local semantic stack is unavailable.
589
+ """
526
590
  text = f"{query or ''}\n{current_context or ''}".strip()
591
+ semantic = _classify_intent_semantic(text)
592
+ if semantic is not None:
593
+ return semantic
594
+ fallback = _classify_intent_fallback(text)
595
+ conservative = _conservative_continuity_fallback(text, fallback)
596
+ if conservative is not None:
597
+ return conservative
598
+ if _demote_cold_generic_continuity(text, fallback):
599
+ return IntentClassification(
600
+ intent="general",
601
+ confidence=0.0,
602
+ scores={**fallback.scores, "semantic_unavailable": 1.0},
603
+ features={
604
+ **fallback.features,
605
+ "cold_generic_continuity_demoted": 1.0,
606
+ },
607
+ language_hints=fallback.language_hints,
608
+ )
609
+ return fallback
610
+
611
+
612
+ def _classify_intent_semantic(text: str) -> IntentClassification | None:
613
+ if not text or not _pre_answer_semantic_intent_enabled():
614
+ return None
615
+ try:
616
+ from semantic_router import route as semantic_route
617
+ except Exception:
618
+ return None
619
+
620
+ result = semantic_route(
621
+ decision_kind=PRE_ANSWER_SEMANTIC_DECISION_KIND,
622
+ question=(
623
+ "Classify the user's pre-answer need into exactly one label. "
624
+ "prior_work means previous actions, reasons, evidence, or why an artifact was touched. "
625
+ "file_location means where a file/project/artifact is. "
626
+ "modify_existing means editing or continuing an existing artifact. "
627
+ "memory_question means asking remembered facts, decisions, or context. "
628
+ "identity_authorship means who did something, which session/client acted, or Nero authorship. "
629
+ "schedule_commitment means promises, pending commitments, reminders, deadlines, or future follow-up. "
630
+ "runtime_diagnosis means diagnosing NEXO/Brain/Desktop/runtime/tools. "
631
+ "general means no pre-answer continuity evidence is needed."
632
+ ),
633
+ context=text,
634
+ labels=PRE_ANSWER_INTENTS,
635
+ allow_remote_fallback=_pre_answer_semantic_remote_enabled(),
636
+ )
637
+ if not getattr(result, "ok", False):
638
+ return None
639
+
640
+ label = str(getattr(result, "label", None) or getattr(result, "verdict", "") or "").strip()
641
+ if label not in PRE_ANSWER_INTENTS:
642
+ return None
643
+
644
+ confidence = _coerce_confidence(getattr(result, "confidence", 0.0), default=0.75)
645
+ normalized = _normalize(text)
646
+ tokens = _plain_tokens(normalized)
647
+ return IntentClassification(
648
+ intent=label,
649
+ confidence=confidence if label != "general" else min(confidence, 0.2),
650
+ scores={label: round(confidence, 4)},
651
+ features={
652
+ "semantic_route": 1.0,
653
+ "semantic_confidence": round(confidence, 4),
654
+ },
655
+ language_hints=_language_hints(tokens),
656
+ )
657
+
658
+
659
+ def _classify_intent_fallback(text: str) -> IntentClassification:
527
660
  normalized = _normalize(text)
528
661
  tokens = _plain_tokens(normalized)
529
662
  features = {name: _feature_score(tokens, terms) for name, terms in _FEATURE_LEXICON.items()}
@@ -563,6 +696,108 @@ def classify_intent(query: str, *, current_context: str = "") -> IntentClassific
563
696
  )
564
697
 
565
698
 
699
+ def _conservative_continuity_fallback(
700
+ text: str,
701
+ fallback: IntentClassification,
702
+ ) -> IntentClassification | None:
703
+ """Route short operator continuity questions to safe evidence sources.
704
+
705
+ This fallback is only a cold-start safety net. The semantic router remains
706
+ the primary detector; here we require an operational anchor so generic
707
+ questions do not inject unrelated memory or open commitments.
708
+ """
709
+ normalized = _normalize(text)
710
+ tokens = _plain_tokens(normalized)
711
+ if not tokens:
712
+ return None
713
+ question_like = "?" in text or normalized.startswith(("que ", "qué ", "what ", "which ", "who ", "quien ", "donde ", "where ", "why ", "por que ", "por qué "))
714
+ if not question_like or len(tokens) > 9:
715
+ return None
716
+ pathish = bool(_PATHISH_RE.search(normalized))
717
+ if (
718
+ fallback.intent == "file_location"
719
+ and pathish
720
+ and fallback.features.get("location", 0.0) <= 0.95
721
+ ):
722
+ return IntentClassification(
723
+ intent="prior_work",
724
+ confidence=0.40,
725
+ scores={"prior_work": 0.40, "semantic_unavailable": 1.0},
726
+ features={"conservative_continuity_fallback": 1.0, "path_context": 1.0},
727
+ language_hints=_language_hints(tokens),
728
+ )
729
+ if fallback.intent in {"file_location", "modify_existing", "runtime_diagnosis"} and fallback.confidence >= 0.60:
730
+ return None
731
+ if pathish and fallback.intent == "file_location":
732
+ return None
733
+ if not _cold_continuity_anchor(tokens):
734
+ return None
735
+ return IntentClassification(
736
+ intent="memory_question",
737
+ confidence=0.35,
738
+ scores={"memory_question": 0.35, "semantic_unavailable": 1.0},
739
+ features={"conservative_continuity_fallback": 1.0},
740
+ language_hints=_language_hints(tokens),
741
+ )
742
+
743
+
744
+ def _cold_continuity_anchor(tokens: Iterable[str]) -> bool:
745
+ token_list = list(tokens)
746
+ if not token_list:
747
+ return False
748
+ if any(token.startswith(stem) for token in token_list for stem in _COLD_CONTINUITY_STEMS):
749
+ return True
750
+ if _COLD_OPERATOR_TOKENS.intersection(token_list):
751
+ return _feature_score(token_list, _FEATURE_LEXICON["past_work"]) >= 0.60
752
+ return False
753
+
754
+
755
+ def _demote_cold_generic_continuity(text: str, fallback: IntentClassification) -> bool:
756
+ if fallback.intent not in {"prior_work", "memory_question"}:
757
+ return False
758
+ normalized = _normalize(text)
759
+ tokens = _plain_tokens(normalized)
760
+ if not tokens or len(tokens) > 9:
761
+ return False
762
+ question_like = "?" in text or normalized.startswith(("que ", "qué ", "what ", "which ", "who ", "quien ", "donde ", "where ", "why ", "por que ", "por qué "))
763
+ if not question_like:
764
+ return False
765
+ if _PATHISH_RE.search(normalized):
766
+ return False
767
+ return not _cold_continuity_anchor(tokens)
768
+
769
+
770
+ def _cold_commitment_question(text: str) -> bool:
771
+ tokens = _plain_tokens(_normalize(text))
772
+ if not tokens:
773
+ return False
774
+ if any(token.startswith(stem) for token in tokens for stem in _COLD_COMMITMENT_STEMS):
775
+ return True
776
+ return _feature_score(tokens, _FEATURE_LEXICON["schedule"]) >= 0.60
777
+
778
+
779
+ def _pre_answer_semantic_intent_enabled() -> bool:
780
+ value = os.environ.get("NEXO_PRE_ANSWER_SEMANTIC_INTENT", "1")
781
+ return str(value or "").strip().lower() not in {"0", "false", "no", "off", "disabled"}
782
+
783
+
784
+ def _pre_answer_semantic_remote_enabled() -> bool:
785
+ value = os.environ.get("NEXO_PRE_ANSWER_SEMANTIC_REMOTE", "0")
786
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
787
+
788
+
789
+ def _coerce_confidence(value: Any, *, default: float) -> float:
790
+ try:
791
+ parsed = float(value)
792
+ except Exception:
793
+ return default
794
+ if parsed < 0.0:
795
+ return 0.0
796
+ if parsed > 1.0:
797
+ return 1.0
798
+ return parsed
799
+
800
+
566
801
  def plan_sources(intent: str) -> SourcePlan:
567
802
  return _SOURCE_PLANS.get(intent, _SOURCE_PLANS["general"])
568
803
 
@@ -725,6 +960,7 @@ def default_source_adapters() -> dict[str, SourceAdapter]:
725
960
  "filesystem": _source_filesystem,
726
961
  "guard_context": _source_guard_context,
727
962
  "cognitive": _source_cognitive,
963
+ "commitments": _source_commitments,
728
964
  "continuity": _source_continuity,
729
965
  "reminders": _source_reminders,
730
966
  "followups": _source_followups,
@@ -1354,13 +1590,69 @@ def _source_guard_context(request: SourceRequest) -> SourceResult:
1354
1590
 
1355
1591
 
1356
1592
  def _source_cognitive(request: SourceRequest) -> SourceResult:
1357
- rows = _query_table_like(
1358
- "memory_observations",
1593
+ from memory_retrieval import memory_search
1594
+
1595
+ result = memory_search(
1359
1596
  request.query,
1360
- columns=("title", "summary", "content", "source"),
1597
+ project_hint=request.area,
1598
+ depth="evidence",
1361
1599
  limit=4,
1600
+ process_queue=True,
1601
+ )
1602
+ candidates = result.get("candidates") or []
1603
+ if not candidates:
1604
+ return SourceResult(source="cognitive")
1605
+ lines = []
1606
+ refs: list[str] = []
1607
+ for item in candidates[:4]:
1608
+ refs.extend(str(ref) for ref in item.get("evidence_refs") or [])
1609
+ lines.append(
1610
+ "- "
1611
+ + " | ".join(
1612
+ part
1613
+ for part in (
1614
+ f"type={item.get('type')}" if item.get("type") else "",
1615
+ f"subject={_clip(str(item.get('subject') or ''), 160)}" if item.get("subject") else "",
1616
+ f"summary={_clip(str(item.get('summary') or ''), 320)}" if item.get("summary") else "",
1617
+ )
1618
+ if part
1619
+ )
1620
+ )
1621
+ return SourceResult(
1622
+ source="cognitive",
1623
+ rendered=_clip("\n".join(lines), request.max_chars),
1624
+ evidence_refs=list(dict.fromkeys(refs)),
1625
+ result_count=len(candidates),
1626
+ )
1627
+
1628
+
1629
+ def _source_commitments(request: SourceRequest) -> SourceResult:
1630
+ from db import list_commitments
1631
+
1632
+ rows = list_commitments(
1633
+ query=request.query,
1634
+ status="",
1635
+ session_id=request.sid if request.intent == "identity_authorship" else "",
1636
+ project_key=request.area,
1637
+ limit=6,
1638
+ )
1639
+ if not rows and (
1640
+ request.intent == "schedule_commitment"
1641
+ or (request.intent == "memory_question" and _cold_commitment_question(request.query))
1642
+ ):
1643
+ rows = list_commitments(
1644
+ query="",
1645
+ status="open",
1646
+ session_id=request.sid,
1647
+ project_key=request.area,
1648
+ limit=6,
1649
+ )
1650
+ return _rows_result(
1651
+ "commitments",
1652
+ rows,
1653
+ ("id", "status", "deadline", "owner", "statement", "action_ref_type", "action_ref_id", "evidence_ref"),
1654
+ request.max_chars,
1362
1655
  )
1363
- return _rows_result("cognitive", rows, ("id", "title", "summary", "source"), request.max_chars)
1364
1656
 
1365
1657
 
1366
1658
  def _source_continuity(request: SourceRequest) -> SourceResult:
@@ -4,7 +4,7 @@ Phase 2 Protocol Enforcer Phase C (Layer 2) item R14. Plan doc 1 reads:
4
4
 
5
5
  IF the last user message -> cognitive_sentiment.is_correction = true
6
6
  OR valence < -0.4
7
- AND nexo_learning_add does NOT appear in the next 3 tool calls
7
+ AND nexo_learning_add does NOT appear in the next 2 tool calls
8
8
  THEN inject the obligation.
9
9
 
10
10
  Implementation contract:
@@ -17,7 +17,7 @@ Implementation contract:
17
17
  False. Downstream R28 (system prompt) and the auto_capture hook
18
18
  still cover the gap; we would rather miss a correction than
19
19
  harass the agent with false-positive R14 injections.
20
- - The "3-tool-calls window" state lives in the caller
20
+ - The "2-tool-calls window" state lives in the caller
21
21
  (HeadlessEnforcer / Desktop EnforcementEngine). This module only
22
22
  exposes the pure decision function and the structured injection
23
23
  prompt.
@@ -37,7 +37,7 @@ POSITIVE_LABEL = "negative_feedback"
37
37
  INJECTION_PROMPT_TEMPLATE = render_core_prompt("r14-correction-learning-injection")
38
38
 
39
39
 
40
- DEFAULT_WINDOW_TOOL_CALLS = 3
40
+ DEFAULT_WINDOW_TOOL_CALLS = 2
41
41
 
42
42
 
43
43
  def detect_correction(user_text: str, *, classifier=None) -> bool:
@@ -1,6 +1,10 @@
1
1
  # NEXO Brain — runtime dependencies
2
2
  # Core (required)
3
- fastmcp>=2.9.0
3
+ # v7.27.5 — cap fastmcp <3.2.0 hasta validar 3.2.x. Builds intermedias 3.x
4
+ # perdieron el re-export de ToolResult desde fastmcp.tools (live failure
5
+ # en Win10 22H2 Azure VM 2026-05-28). Mantenemos el fallback de import en
6
+ # src/runtime_versioning.py por defensa-en-profundidad.
7
+ fastmcp>=2.9.0,<3.2.0
4
8
  numpy
5
9
  tomli; python_version < "3.11"
6
10
 
@@ -13,7 +13,17 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
 
15
15
  from fastmcp.server.middleware import Middleware
16
- from fastmcp.tools import ToolResult
16
+ # v7.27.5 — fastmcp 3.x mantiene ToolResult exportado en fastmcp.tools
17
+ # (vía __init__.py re-export de .tool.ToolResult), pero algunas builds
18
+ # intermedias tras la transición 2.x→3.x perdieron el re-export y el import
19
+ # simple falla con `ImportError: cannot import name 'ToolResult' from
20
+ # 'fastmcp.tools'`. Fallback al path canónico .tools.tool para sobrevivir
21
+ # tanto a la versión nueva como a las builds inestables. Confirmado live en
22
+ # máquina virgen Win10 22H2 Azure VM 2026-05-28.
23
+ try:
24
+ from fastmcp.tools import ToolResult # noqa: F401
25
+ except ImportError:
26
+ from fastmcp.tools.tool import ToolResult # type: ignore[no-redef]
17
27
 
18
28
  import paths
19
29
 
@@ -44,6 +44,7 @@ class SavedNotUsedConfig:
44
44
 
45
45
  nexo_db_path: Path | None = None
46
46
  local_context_db_path: Path | None = None
47
+ local_context_usage_db_path: Path | None = None
47
48
  email_db_path: Path | None = None
48
49
  transcript_roots: tuple[Path, ...] = ()
49
50
  desktop_conversations_path: Path | None = None
@@ -89,11 +90,13 @@ def default_config() -> SavedNotUsedConfig:
89
90
  if paths is not None:
90
91
  nexo_db = _safe_path_call(paths.resolve_db_path)
91
92
  local_context = Path(os.environ.get("NEXO_LOCAL_CONTEXT_DB", "")) if os.environ.get("NEXO_LOCAL_CONTEXT_DB") else _safe_path_call(lambda: paths.memory_dir() / "local-context.db")
93
+ local_context_usage = Path(os.environ.get("NEXO_LOCAL_CONTEXT_USAGE_DB", "")) if os.environ.get("NEXO_LOCAL_CONTEXT_USAGE_DB") else _safe_path_call(lambda: paths.memory_dir() / "local-context-usage.db")
92
94
  email_db = _safe_path_call(lambda: paths.nexo_email_dir() / "nexo-email.db")
93
95
  cron_spool = _safe_path_call(lambda: paths.operations_dir() / "cron-spool")
94
96
  else:
95
97
  nexo_db = home / "runtime" / "data" / "nexo.db"
96
98
  local_context = home / "runtime" / "memory" / "local-context.db"
99
+ local_context_usage = home / "runtime" / "memory" / "local-context-usage.db"
97
100
  email_db = home / "runtime" / "nexo-email" / "nexo-email.db"
98
101
  cron_spool = home / "runtime" / "operations" / "cron-spool"
99
102
 
@@ -106,6 +109,7 @@ def default_config() -> SavedNotUsedConfig:
106
109
  return SavedNotUsedConfig(
107
110
  nexo_db_path=nexo_db,
108
111
  local_context_db_path=local_context,
112
+ local_context_usage_db_path=local_context_usage,
109
113
  email_db_path=email_db,
110
114
  transcript_roots=transcript_roots,
111
115
  desktop_conversations_path=desktop_dir / "conversations.json",
@@ -197,7 +201,7 @@ def _audit_local_context(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[S
197
201
  producer = "nexo-local-index.py -> local_context.api"
198
202
  consumer = "nexo_context_router / nexo_local_context / pre_action_context"
199
203
  risk = "Local index is written but may not be consulted before answering."
200
- test = "local_assets/chunks/entities > 0 requires a recent local_context_queries row."
204
+ test = "local_assets/chunks/entities > 0 requires recent used_before_response usage events."
201
205
  if not cfg.local_context_db_path or not cfg.local_context_db_path.exists():
202
206
  row = _row("local_context", producer, store, consumer, "", "", risk, test, "missing", "P2", {"path_exists": False})
203
207
  return row, []
@@ -207,14 +211,19 @@ def _audit_local_context(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[S
207
211
  write_counts = {table: _count(conn, table) for table in LOCAL_CONTEXT_WRITE_TABLES}
208
212
  query_count = _count(conn, "local_context_queries")
209
213
  last_write = _max_many(conn, [(table, ("updated_at", "last_seen_at", "created_at")) for table in LOCAL_CONTEXT_WRITE_TABLES])
210
- last_use = _max_timestamp(conn, "local_context_queries", ("created_at", "updated_at"))
214
+ legacy_last_use = _max_timestamp(conn, "local_context_queries", ("created_at", "updated_at"))
211
215
  evidence.update({"write_counts": write_counts, "query_count": query_count})
216
+ usage_stats = _local_context_usage_stats(cfg)
217
+ last_use = usage_stats.get("latest_used_before_response_at") or legacy_last_use
218
+ real_use_count = int(usage_stats.get("used_before_response_count") or 0)
219
+ evidence["usage_events"] = usage_stats
220
+ evidence["legacy_last_use"] = legacy_last_use
212
221
 
213
222
  findings: list[SavedNotUsedFinding] = []
214
223
  total_writes = sum(write_counts.values())
215
224
  status = "ok"
216
225
  severity = "OK"
217
- if total_writes and not query_count:
226
+ if total_writes and not real_use_count:
218
227
  status = "saved_not_used"
219
228
  severity = "P0"
220
229
  findings.append(
@@ -273,6 +282,38 @@ def _audit_local_context(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[S
273
282
  return _row("local_context", producer, store, consumer, last_write, last_use, risk, test, status, severity, evidence), findings
274
283
 
275
284
 
285
+ def _local_context_usage_stats(cfg: SavedNotUsedConfig) -> dict[str, Any]:
286
+ usage_path = cfg.local_context_usage_db_path
287
+ if usage_path is None and cfg.local_context_db_path is not None:
288
+ usage_path = cfg.local_context_db_path.with_name("local-context-usage.db")
289
+ evidence = {
290
+ "path": _path_text(usage_path),
291
+ "path_exists": bool(usage_path and usage_path.exists()),
292
+ "total": 0,
293
+ "used_before_response_count": 0,
294
+ "latest_at": "",
295
+ "latest_used_before_response_at": "",
296
+ }
297
+ if not usage_path or not usage_path.exists():
298
+ return evidence
299
+ with _connect(usage_path) as conn:
300
+ table = "local_context_usage_events"
301
+ if not _table_exists(conn, table):
302
+ return evidence
303
+ evidence["total"] = _count(conn, table)
304
+ evidence["used_before_response_count"] = _count_where(conn, table, "COALESCE(used_before_response,0) != 0")
305
+ evidence["latest_at"] = _max_timestamp(conn, table, ("created_at",))
306
+ try:
307
+ row = conn.execute(
308
+ f"SELECT MAX(created_at) AS value FROM {_quote(table)} WHERE COALESCE(used_before_response,0) != 0"
309
+ ).fetchone()
310
+ if row and row["value"] not in (None, ""):
311
+ evidence["latest_used_before_response_at"] = _time_text(row["value"])
312
+ except sqlite3.Error:
313
+ pass
314
+ return evidence
315
+
316
+
276
317
  def _audit_memory_pipeline(cfg: SavedNotUsedConfig) -> tuple[StoreAuditRow, list[SavedNotUsedFinding]]:
277
318
  store_id = "memory_observations_pipeline"
278
319
  producer = "record_memory_event"