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.
@@ -33,6 +33,8 @@ import re
33
33
  import sqlite3
34
34
  import subprocess
35
35
  import sys
36
+ from difflib import SequenceMatcher
37
+ from email.utils import parsedate_to_datetime
36
38
  from datetime import datetime, date, timedelta
37
39
  from pathlib import Path
38
40
 
@@ -79,6 +81,8 @@ MAX_STALE_TRIAGE_PER_RUN = 8
79
81
  MAX_NEEDS_OPERATOR_BRIEFING = 12
80
82
  DEFAULT_ASSISTANT_NAME = "Nova"
81
83
  DEFAULT_OPERATOR_LANGUAGE = "en"
84
+ DEFAULT_STEADY_STATE_IMAP_HOURS = 12
85
+ DEFAULT_DUPLICATE_NOTE_THRESHOLD = 0.9
82
86
 
83
87
  # ── Logging ─────────────────────────────────────────────────────────────
84
88
  def log(msg: str):
@@ -103,6 +107,159 @@ def save_state(state: dict):
103
107
  STATE_FILE.write_text(json.dumps(state, indent=2))
104
108
 
105
109
 
110
+ def load_runner_config() -> dict:
111
+ config = {
112
+ "steady_state_imap_hours": DEFAULT_STEADY_STATE_IMAP_HOURS,
113
+ "duplicate_note_threshold": DEFAULT_DUPLICATE_NOTE_THRESHOLD,
114
+ }
115
+ config_path = NEXO_HOME / "personal" / "config" / "followup-runner.json"
116
+ if config_path.exists():
117
+ try:
118
+ raw = json.loads(config_path.read_text())
119
+ if isinstance(raw, dict):
120
+ config.update(raw)
121
+ except Exception as exc:
122
+ log(f"Failed to read followup-runner config ({exc})")
123
+ try:
124
+ config["steady_state_imap_hours"] = float(
125
+ os.environ.get("NEXO_FOLLOWUP_STEADY_STATE_IMAP_HOURS", config["steady_state_imap_hours"])
126
+ )
127
+ except (TypeError, ValueError):
128
+ config["steady_state_imap_hours"] = DEFAULT_STEADY_STATE_IMAP_HOURS
129
+ try:
130
+ config["duplicate_note_threshold"] = float(
131
+ os.environ.get("NEXO_FOLLOWUP_DUPLICATE_NOTE_THRESHOLD", config["duplicate_note_threshold"])
132
+ )
133
+ except (TypeError, ValueError):
134
+ config["duplicate_note_threshold"] = DEFAULT_DUPLICATE_NOTE_THRESHOLD
135
+ return config
136
+
137
+
138
+ def _normalise_note_text(value: str) -> str:
139
+ text = re.sub(r"\s+", " ", str(value or "").strip().lower())
140
+ text = re.sub(r"\b\d{4}-\d{2}-\d{2}(?:[t ][0-9:.+-z]+)?\b", "<date>", text)
141
+ text = re.sub(r"\b[0-9]{6,}(?:\.[0-9]+)?\b", "<num>", text)
142
+ return text
143
+
144
+
145
+ def _note_similarity(left: str, right: str) -> float:
146
+ left_norm = _normalise_note_text(left)
147
+ right_norm = _normalise_note_text(right)
148
+ if not left_norm or not right_norm:
149
+ return 0.0
150
+ return SequenceMatcher(None, left_norm, right_norm).ratio()
151
+
152
+
153
+ def _latest_email_gap_hours() -> float | None:
154
+ email_db = NEXO_HOME / "runtime" / "nexo-email" / "nexo-email.db"
155
+ if not email_db.exists():
156
+ return None
157
+ try:
158
+ conn = sqlite3.connect(str(email_db))
159
+ row = conn.execute(
160
+ "SELECT received_at FROM emails WHERE received_at IS NOT NULL "
161
+ "ORDER BY received_at DESC LIMIT 1"
162
+ ).fetchone()
163
+ conn.close()
164
+ except Exception:
165
+ return None
166
+ if not row or not row[0]:
167
+ return None
168
+ raw = str(row[0]).strip().replace("Z", "+00:00")
169
+ try:
170
+ received = datetime.fromisoformat(raw)
171
+ except ValueError:
172
+ try:
173
+ received = parsedate_to_datetime(raw)
174
+ except (TypeError, ValueError):
175
+ return None
176
+ if received.tzinfo is not None:
177
+ received = received.astimezone().replace(tzinfo=None)
178
+ return max(0.0, (datetime.now() - received).total_seconds() / 3600)
179
+
180
+
181
+ def _db_change_marker() -> dict:
182
+ if not NEXO_DB.exists():
183
+ return {}
184
+ try:
185
+ conn = sqlite3.connect(str(NEXO_DB))
186
+ row = conn.execute(
187
+ "SELECT COUNT(*), COALESCE(MAX(updated_at), 0) FROM followups "
188
+ "WHERE UPPER(status) NOT LIKE 'COMPLETED%' AND UPPER(status) != 'DELETED'"
189
+ ).fetchone()
190
+ history = conn.execute(
191
+ "SELECT COALESCE(MAX(created_at), 0) FROM item_history "
192
+ "WHERE actor IS NULL OR actor != 'followup-runner'"
193
+ ).fetchone()
194
+ conn.close()
195
+ except Exception:
196
+ return {}
197
+ return {
198
+ "active_followups": int(row[0] or 0) if row else 0,
199
+ "max_followup_updated_at": float(row[1] or 0) if row else 0,
200
+ "max_non_runner_history_at": float(history[0] or 0) if history else 0,
201
+ }
202
+
203
+
204
+ def _recent_runner_notes(fu_id: str, *, limit: int = 4) -> list[str]:
205
+ try:
206
+ conn = sqlite3.connect(str(NEXO_DB))
207
+ rows = conn.execute(
208
+ "SELECT note FROM item_history "
209
+ "WHERE item_type='followup' AND item_id=? AND actor='followup-runner' "
210
+ "ORDER BY created_at DESC LIMIT ?",
211
+ (fu_id, limit),
212
+ ).fetchall()
213
+ conn.close()
214
+ except Exception:
215
+ return []
216
+ return [str(row[0] or "") for row in rows if str(row[0] or "").strip()]
217
+
218
+
219
+ def _is_duplicate_steady_state(followup: dict, *, threshold: float) -> bool:
220
+ notes = _recent_runner_notes(str(followup.get("id") or ""))
221
+ if not notes:
222
+ return False
223
+ current = "\n".join(
224
+ part
225
+ for part in (
226
+ str(followup.get("description") or "").strip(),
227
+ str(followup.get("verification") or "").strip(),
228
+ str(followup.get("reasoning") or "").strip(),
229
+ )
230
+ if part
231
+ )
232
+ if current and max(_note_similarity(current, note) for note in notes) >= threshold:
233
+ return True
234
+ if len(notes) >= 2 and _note_similarity(notes[0], notes[1]) >= threshold:
235
+ return True
236
+ return False
237
+
238
+
239
+ def split_steady_state_skips(actionable: list[dict], state: dict, config: dict) -> tuple[list[dict], list[dict]]:
240
+ """Skip repeated no-op followups when email and DB state have not moved."""
241
+ marker = _db_change_marker()
242
+ previous_marker = (state.get("_meta") or {}).get("last_db_change_marker")
243
+ email_gap = _latest_email_gap_hours()
244
+ threshold = float(config.get("duplicate_note_threshold") or DEFAULT_DUPLICATE_NOTE_THRESHOLD)
245
+ imap_hours = float(config.get("steady_state_imap_hours") or DEFAULT_STEADY_STATE_IMAP_HOURS)
246
+ steady_state = bool(marker and previous_marker == marker and email_gap is not None and email_gap < imap_hours)
247
+ if not steady_state:
248
+ return actionable, []
249
+
250
+ kept = []
251
+ skipped = []
252
+ for followup in actionable:
253
+ if _is_duplicate_steady_state(followup, threshold=threshold):
254
+ skipped.append(followup)
255
+ else:
256
+ kept.append(followup)
257
+ if skipped:
258
+ ids = ", ".join(str(item.get("id") or "") for item in skipped)
259
+ log(f"Steady-state auto-skip: {ids} (IMAP gap {email_gap:.1f}h, DB marker unchanged)")
260
+ return kept, skipped
261
+
262
+
106
263
  # ── DB access ───────────────────────────────────────────────────────────
107
264
  def _parse_date(value: str) -> date | None:
108
265
  try:
@@ -670,6 +827,8 @@ def release_lock():
670
827
  def get_recent_activity(hours: int = 24) -> str:
671
828
  """Build a summary of what the runner did in the last N hours."""
672
829
  lines = []
830
+ config = load_runner_config()
831
+ duplicate_threshold = float(config.get("duplicate_note_threshold") or DEFAULT_DUPLICATE_NOTE_THRESHOLD)
673
832
  try:
674
833
  conn = sqlite3.connect(str(NEXO_DB))
675
834
  conn.row_factory = sqlite3.Row
@@ -698,13 +857,25 @@ def get_recent_activity(hours: int = 24) -> str:
698
857
  if notes:
699
858
  lines.append("\nFOLLOWUP NOTES WRITTEN (last 24h):")
700
859
  seen = set()
860
+ compressed = 0
701
861
  for n in notes:
702
862
  fid = str(n["followup_id"] or "")
703
863
  if fid in seen:
704
864
  continue
705
865
  seen.add(fid)
706
866
  note_text = str(n["note"] or "")[:150]
867
+ previous = conn.execute(
868
+ "SELECT note FROM item_history "
869
+ "WHERE item_type='followup' AND item_id=? AND actor='followup-runner' "
870
+ "AND created_at < ? ORDER BY created_at DESC LIMIT 1",
871
+ (fid, n["created_at"]),
872
+ ).fetchone()
873
+ if previous and _note_similarity(str(previous["note"] or ""), note_text) >= duplicate_threshold:
874
+ compressed += 1
875
+ note_text = f"{note_text} [nota similar compactada]"
707
876
  lines.append(f" {fid}: {note_text}")
877
+ if compressed:
878
+ lines.append(f" ({compressed} notas duplicadas compactadas; umbral={duplicate_threshold:.2f})")
708
879
 
709
880
  conn.close()
710
881
  except Exception as e:
@@ -799,8 +970,10 @@ def main():
799
970
  return
800
971
 
801
972
  state = load_state()
973
+ runner_config = load_runner_config()
802
974
  groups = get_all_active_followups(state)
803
975
  all_actionable = list(groups["actionable"])
976
+ all_actionable, steady_skipped = split_steady_state_skips(all_actionable, state, runner_config)
804
977
  cooled = groups.get("cooled_down", [])
805
978
  stale_triage = groups.get("stale_triage", [])
806
979
 
@@ -835,6 +1008,23 @@ def main():
835
1008
  record_attempt(state, fid, "stale_review")
836
1009
 
837
1010
  results = []
1011
+ for fu in steady_skipped:
1012
+ fid = str(fu.get("id") or "")
1013
+ if not fid:
1014
+ continue
1015
+ record_attempt(state, fid, "steady_state")
1016
+ results.append(
1017
+ {
1018
+ "id": fid,
1019
+ "status": "checked",
1020
+ "summary": (
1021
+ "Auto-skip steady-state: marcador DB sin cambios desde el ciclo anterior, "
1022
+ f"último IMAP <{runner_config.get('steady_state_imap_hours')}h y nota previa similar."
1023
+ ),
1024
+ "needs_attention": False,
1025
+ "options": None,
1026
+ }
1027
+ )
838
1028
 
839
1029
  if all_actionable:
840
1030
  # Clean previous results
@@ -940,10 +1130,14 @@ def main():
940
1130
  else:
941
1131
  log("No followups at all. Runner direct email path removed.")
942
1132
 
1133
+ if steady_skipped:
1134
+ RESULTS_FILE.write_text(json.dumps({"results": results}, indent=2, ensure_ascii=False))
1135
+
943
1136
  # Save state with attempts + last run
944
1137
  if "_meta" not in state:
945
1138
  state["_meta"] = {}
946
1139
  state["_meta"]["last_run"] = datetime.now().isoformat()
1140
+ state["_meta"]["last_db_change_marker"] = _db_change_marker()
947
1141
  save_state(state)
948
1142
 
949
1143
  log("Done.")
@@ -83,12 +83,12 @@ def _collect_local_votes(
83
83
  vote aggregator can still detect quorum problems.
84
84
  """
85
85
  try:
86
- from classifier_local import LocalZeroShotClassifier
86
+ from classifier_local import get_shared_zero_shot_classifier
87
87
  except Exception as exc: # pragma: no cover
88
88
  _logger.debug("semantic_reasoner: classifier_local unavailable (%s)", exc)
89
89
  return []
90
90
 
91
- clf = LocalZeroShotClassifier(confidence_floor=0.0)
91
+ clf = get_shared_zero_shot_classifier(confidence_floor=0.0)
92
92
  votes: list[tuple[str, float, dict[str, float]]] = []
93
93
  for template in _PROMPT_PERTURBATIONS:
94
94
  prompt = template.format(q=question)
@@ -30,6 +30,7 @@ the decision carries. This is also what Desktop will consume via the
30
30
  from __future__ import annotations
31
31
 
32
32
  import logging
33
+ import os
33
34
  from dataclasses import dataclass, field
34
35
  from typing import Any
35
36
 
@@ -79,7 +80,7 @@ class RouterResult:
79
80
  # Decision kinds + policy table
80
81
  # ---------------------------------------------------------------------------
81
82
  #
82
- # The plan enumerates 18 decision_kinds that need to route through here. They
83
+ # The plan enumerates the decision_kinds that need to route through here. They
83
84
  # fall into two families:
84
85
  #
85
86
  # TEXTUAL — the first-line local classifier is good enough; the
@@ -111,6 +112,7 @@ TEXTUAL_KINDS: tuple[str, ...] = (
111
112
  "reply_event_type",
112
113
  "query_intent",
113
114
  "sentiment_intent",
115
+ "pre_answer_intent",
114
116
  )
115
117
 
116
118
 
@@ -136,10 +138,21 @@ _POLICY: dict[str, dict[str, Any]] = {
136
138
  "reasoner_mode": "multipass_local",
137
139
  "reasoner_threshold": 0.75,
138
140
  "allow_remote_fallback": True,
141
+ "allow_cold_local_load": True,
139
142
  }
140
143
  for kind in TEXTUAL_KINDS
141
144
  }
142
145
 
146
+ _POLICY["pre_answer_intent"].update(
147
+ {
148
+ # Pre-answer sits on the user-visible latency path. Use the local
149
+ # semantic classifier when it is already warm, but do not cold-load a
150
+ # heavy model before answering unless explicitly enabled.
151
+ "allow_remote_fallback": True,
152
+ "allow_cold_local_load": False,
153
+ }
154
+ )
155
+
143
156
  _POLICY.update(
144
157
  {
145
158
  kind: {
@@ -148,6 +161,7 @@ _POLICY.update(
148
161
  "reasoner_mode": "cached_llm",
149
162
  "reasoner_threshold": 0.60,
150
163
  "allow_remote_fallback": True,
164
+ "allow_cold_local_load": True,
151
165
  }
152
166
  for kind in CODE_AWARE_KINDS
153
167
  }
@@ -176,6 +190,7 @@ def _run_fast_local(
176
190
  context: str = "",
177
191
  labels: tuple[str, ...],
178
192
  confidence_floor: float,
193
+ allow_cold_load: bool = True,
179
194
  ) -> RouterResult | None:
180
195
  """Try ``LocalZeroShotClassifier``. Return None on unavailable or
181
196
  below-threshold so the router advances.
@@ -187,12 +202,14 @@ def _run_fast_local(
187
202
  when present, and fall back to question for simple direct callers.
188
203
  """
189
204
  try:
190
- from classifier_local import LocalZeroShotClassifier
205
+ from classifier_local import get_shared_zero_shot_classifier, is_local_classifier_warm
191
206
  except Exception as exc: # pragma: no cover — install not ready
192
207
  _logger.debug("semantic_router: classifier_local unavailable (%s)", exc)
193
208
  return None
194
209
 
195
- clf = LocalZeroShotClassifier(confidence_floor=confidence_floor)
210
+ if not allow_cold_load and not is_local_classifier_warm():
211
+ return None
212
+ clf = get_shared_zero_shot_classifier(confidence_floor=confidence_floor)
196
213
  classifier_input = (context or "").strip() or question
197
214
  result = clf.classify(classifier_input, labels)
198
215
  if result is None:
@@ -375,6 +392,28 @@ def _normalize_remote_answer(
375
392
  # ---------------------------------------------------------------------------
376
393
 
377
394
 
395
+ def _env_bool(name: str, *, default: bool) -> bool:
396
+ raw = os.environ.get(name)
397
+ if raw is None:
398
+ return default
399
+ return str(raw or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
400
+
401
+
402
+ def _allow_cold_local_load(decision_kind: str, policy: dict[str, Any]) -> bool:
403
+ default = bool(policy.get("allow_cold_local_load", True))
404
+ if decision_kind == "pre_answer_intent":
405
+ return _env_bool("NEXO_PRE_ANSWER_ALLOW_COLD_SEMANTIC_LOAD", default=default)
406
+ return default
407
+
408
+
409
+ def _local_classifier_warm() -> bool:
410
+ try:
411
+ from classifier_local import is_local_classifier_warm
412
+ except Exception:
413
+ return False
414
+ return is_local_classifier_warm()
415
+
416
+
378
417
  def route(
379
418
  *,
380
419
  decision_kind: str,
@@ -407,6 +446,7 @@ def route(
407
446
  labels_tuple: tuple[str, ...] | None = (
408
447
  tuple(labels) if labels else None
409
448
  )
449
+ allow_cold_load = _allow_cold_local_load(decision_kind, policy)
410
450
 
411
451
  # Step 1 — fast_local for textual families only.
412
452
  if policy["fast_local_threshold"] is not None and labels_tuple:
@@ -415,20 +455,27 @@ def route(
415
455
  context=context,
416
456
  labels=labels_tuple,
417
457
  confidence_floor=float(policy["fast_local_threshold"]),
458
+ allow_cold_load=allow_cold_load,
418
459
  )
419
460
  if fast is not None:
420
461
  fast.decision_kind = decision_kind
421
462
  return fast
422
463
 
423
464
  # Step 2 — semantic_reasoner (Mode A or B depending on policy).
424
- reasoned = _run_semantic_reasoner(
425
- decision_kind=decision_kind,
426
- question=question,
427
- labels=labels_tuple,
428
- context=context,
429
- mode=str(policy["reasoner_mode"]),
430
- confidence_floor=float(policy["reasoner_threshold"]),
431
- )
465
+ reasoned = None
466
+ if not (
467
+ str(policy["reasoner_mode"]) == "multipass_local"
468
+ and not allow_cold_load
469
+ and not _local_classifier_warm()
470
+ ):
471
+ reasoned = _run_semantic_reasoner(
472
+ decision_kind=decision_kind,
473
+ question=question,
474
+ labels=labels_tuple,
475
+ context=context,
476
+ mode=str(policy["reasoner_mode"]),
477
+ confidence_floor=float(policy["reasoner_threshold"]),
478
+ )
432
479
  if reasoned is not None and reasoned.ok:
433
480
  return reasoned
434
481
 
package/src/server.py CHANGED
@@ -92,7 +92,7 @@ from tools_automation_sessions import (
92
92
  handle_session_log_create,
93
93
  handle_session_log_close,
94
94
  )
95
- from plugins.cortex import handle_cortex_check
95
+ from plugins.cortex import handle_cortex_check, handle_cortex_decide
96
96
  from plugins.guard import handle_guard_check
97
97
  from plugins.protocol import (
98
98
  handle_confidence_check,
@@ -632,6 +632,42 @@ def nexo_cortex_check(
632
632
  )
633
633
 
634
634
 
635
+ @mcp.tool
636
+ def nexo_cortex_decide(
637
+ goal: str,
638
+ alternatives: str,
639
+ task_type: str = "execute",
640
+ impact_level: str = "high",
641
+ context_hint: str = "",
642
+ area: str = "",
643
+ constraints: str = "[]",
644
+ evidence_refs: str = "[]",
645
+ session_id: str = "",
646
+ task_id: str = "",
647
+ linked_outcome_id: int = 0,
648
+ goal_profile_id: str = "",
649
+ goal_id: str = "",
650
+ auto_create_outcome: bool = False,
651
+ ) -> str:
652
+ """Evaluate concrete alternatives for a high-impact task and persist the recommendation."""
653
+ return handle_cortex_decide(
654
+ goal,
655
+ alternatives,
656
+ task_type,
657
+ impact_level,
658
+ context_hint,
659
+ area,
660
+ constraints,
661
+ evidence_refs,
662
+ session_id,
663
+ task_id,
664
+ linked_outcome_id,
665
+ goal_profile_id,
666
+ goal_id,
667
+ auto_create_outcome,
668
+ )
669
+
670
+
635
671
  @mcp.tool
636
672
  def nexo_guard_check(files: str = "", area: str = "") -> str:
637
673
  """Check learnings relevant to files/area before reading or editing code."""