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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/bin/windows-wsl-bridge.js +9 -0
- package/package.json +1 -1
- package/src/agent_runner.py +0 -13
- package/src/call_model_raw.py +17 -4
- package/src/classifier_local.py +44 -0
- package/src/client_sync.py +4 -6
- package/src/db/__init__.py +8 -0
- package/src/db/_commitments.py +344 -0
- package/src/db/_memory_v2.py +52 -2
- package/src/db/_schema.py +37 -0
- package/src/desktop_bridge.py +1 -1
- package/src/doctor/providers/runtime.py +14 -3
- package/src/enforcement_engine.py +128 -2
- package/src/hook_guardrails.py +104 -0
- package/src/local_context/api.py +54 -22
- package/src/plugins/protocol.py +96 -0
- package/src/pre_answer_router.py +298 -6
- package/src/r14_correction_learning.py +3 -3
- package/src/requirements.txt +5 -1
- package/src/runtime_versioning.py +11 -1
- package/src/saved_not_used_audit.py +44 -3
- package/src/scripts/nexo-followup-runner.py +194 -0
- package/src/semantic_reasoner.py +2 -2
- package/src/semantic_router.py +58 -11
- package/src/server.py +37 -1
package/src/plugins/protocol.py
CHANGED
|
@@ -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",
|
package/src/pre_answer_router.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1358
|
-
|
|
1593
|
+
from memory_retrieval import memory_search
|
|
1594
|
+
|
|
1595
|
+
result = memory_search(
|
|
1359
1596
|
request.query,
|
|
1360
|
-
|
|
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
|
|
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 "
|
|
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 =
|
|
40
|
+
DEFAULT_WINDOW_TOOL_CALLS = 2
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def detect_correction(user_text: str, *, classifier=None) -> bool:
|
package/src/requirements.txt
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# NEXO Brain — runtime dependencies
|
|
2
2
|
# Core (required)
|
|
3
|
-
fastmcp
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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"
|