nexo-brain 7.30.4 → 7.30.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.4",
3
+ "version": "7.30.5",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.30.4` is the current packaged-runtime line. Patch release over v7.30.3 - local runtime update post-sync now gives bounded Memory Fabric repair enough time to finish, and headless automations now treat `nexo_stop` as a terminal close so followup/deep-sleep runners do not reopen no-op protocol loops.
21
+ Version `7.30.5` is the current packaged-runtime line. Patch release over v7.30.4 - the morning briefing is now Morning preparation, with automatic relevance, changes since yesterday, next actions, relevant public context, and chat-addressable preference settings for non-technical users.
22
+
23
+ Previously in `7.30.4`: patch release over v7.30.3 - local runtime update post-sync now gives bounded Memory Fabric repair enough time to finish, and headless automations now treat `nexo_stop` as a terminal close so followup/deep-sleep runners do not reopen no-op protocol loops.
22
24
 
23
25
  Previously in `7.30.3`: patch release over v7.30.2 - release closeout now protects the freshly written runtime backup from technical pruning and validates protocol evidence against the canonical `runtime/data/nexo.db` layout.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.4",
3
+ "version": "7.30.5",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -14,20 +14,34 @@ SUPPORTED_AUTOMATIONS = {"morning-agent"}
14
14
 
15
15
 
16
16
  MORNING_AGENT_SCHEMA: dict[str, Any] = {
17
- "schema_version": 1,
17
+ "schema_version": 2,
18
18
  "automation": "morning-agent",
19
- "title": "Morning briefing content",
19
+ "title": "Morning preparation",
20
20
  "groups": [
21
21
  {
22
22
  "id": "content",
23
- "label": "Content",
23
+ "label": "What NEXO watches",
24
24
  "items": [
25
+ {
26
+ "id": "auto_relevance",
27
+ "type": "boolean",
28
+ "label": "Automatic relevance",
29
+ "default": True,
30
+ "help": "NEXO decides what matters by urgency, change, impact, confidence and whether there is a useful next action.",
31
+ },
25
32
  {
26
33
  "id": "priorities",
27
34
  "type": "boolean",
28
35
  "label": "Priorities",
29
36
  "default": True,
30
- "help": "The most important things NEXO thinks you should look at first.",
37
+ "help": "The most important things to look at first today.",
38
+ },
39
+ {
40
+ "id": "changes_since_yesterday",
41
+ "type": "boolean",
42
+ "label": "What changed",
43
+ "default": True,
44
+ "help": "Recent changes, new information and moved work that may affect the day.",
31
45
  },
32
46
  {
33
47
  "id": "agenda",
@@ -72,19 +86,25 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
72
86
  "help": "Things that may stop progress, need your decision or could become a problem.",
73
87
  },
74
88
  {
75
- "id": "internal_refs",
89
+ "id": "next_actions",
76
90
  "type": "boolean",
77
- "label": "Internal references",
78
- "default": False,
79
- "help": "Technical file names, IDs or internal references. Keep this off for a cleaner human summary.",
91
+ "label": "Next actions",
92
+ "default": True,
93
+ "help": "A practical closing list of what to do first, what can wait and what needs a decision.",
80
94
  },
81
95
  {
82
- "id": "news",
96
+ "id": "internal_refs",
83
97
  "type": "boolean",
84
- "label": "News",
98
+ "label": "Internal references",
85
99
  "default": False,
86
- "help": "A short set of current public headlines from the configured news feed, included only when the source can be verified.",
100
+ "help": "Technical file names, IDs or internal references. Keep this off for a cleaner human summary.",
87
101
  },
102
+ ],
103
+ },
104
+ {
105
+ "id": "external",
106
+ "label": "Day context",
107
+ "items": [
88
108
  {
89
109
  "id": "weather",
90
110
  "type": "boolean",
@@ -92,6 +112,50 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
92
112
  "default": True,
93
113
  "help": "Today's weather from the location saved in Desktop or your residence in the profile, included only when the forecast can be verified.",
94
114
  },
115
+ {
116
+ "id": "news",
117
+ "type": "boolean",
118
+ "label": "Relevant public context",
119
+ "default": True,
120
+ "help": "Public headlines only when they are current, verifiable and useful for the operator's day, work, location or interests.",
121
+ },
122
+ {
123
+ "id": "news_interests",
124
+ "type": "multi_choice",
125
+ "label": "Public context interests",
126
+ "default": ["automatic"],
127
+ "options": [
128
+ "automatic",
129
+ "business",
130
+ "technology",
131
+ "finance",
132
+ "local",
133
+ "health",
134
+ "legal",
135
+ "education",
136
+ "real_estate",
137
+ "science",
138
+ "culture",
139
+ "sports",
140
+ ],
141
+ "exclusive_options": ["automatic"],
142
+ "help": "Use Automatic so NEXO infers useful topics, or choose areas you want watched in the morning.",
143
+ },
144
+ {
145
+ "id": "excluded_topics",
146
+ "type": "multi_choice",
147
+ "label": "Topics to avoid",
148
+ "default": [],
149
+ "options": ["politics", "sports", "celebrity", "crime", "crypto", "market_noise"],
150
+ "help": "Topics NEXO should avoid unless they are directly relevant to your work or safety.",
151
+ },
152
+ {
153
+ "id": "why_shown",
154
+ "type": "boolean",
155
+ "label": "Explain why",
156
+ "default": False,
157
+ "help": "Adds a short reason when NEXO includes external context, useful while tuning the briefing.",
158
+ },
95
159
  ],
96
160
  },
97
161
  {
@@ -176,6 +240,36 @@ def _iter_schema_items(schema: dict[str, Any]):
176
240
  yield item
177
241
 
178
242
 
243
+ def _normalize_multi_choice(raw_value: Any, item: dict[str, Any]) -> tuple[list[str], list[str]]:
244
+ options = [str(v) for v in list(item.get("options") or [])]
245
+ allowed = set(options)
246
+ warnings: list[str] = []
247
+ if isinstance(raw_value, (list, tuple, set)):
248
+ raw_items = list(raw_value)
249
+ else:
250
+ text = str(raw_value or "").strip()
251
+ if not text:
252
+ raw_items = []
253
+ else:
254
+ raw_items = [part.strip() for part in text.replace(";", ",").split(",")]
255
+ selected: list[str] = []
256
+ for raw_item in raw_items:
257
+ clean = str(raw_item or "").strip()
258
+ if not clean:
259
+ continue
260
+ if clean not in allowed:
261
+ warnings.append(f"{item.get('id')}: invalid option {clean}")
262
+ continue
263
+ if clean not in selected:
264
+ selected.append(clean)
265
+ exclusive = [str(v) for v in list(item.get("exclusive_options") or [])]
266
+ for exclusive_value in exclusive:
267
+ if exclusive_value in selected and len(selected) > 1:
268
+ selected = [exclusive_value]
269
+ break
270
+ return selected, warnings
271
+
272
+
179
273
  def default_automation_preferences(name: str) -> dict[str, Any]:
180
274
  schema = get_automation_preference_schema(name)
181
275
  values: dict[str, Any] = {}
@@ -218,6 +312,10 @@ def validate_automation_preferences(name: str, payload: dict[str, Any] | None) -
218
312
  values[key] = clean
219
313
  else:
220
314
  warnings.append(f"{key}: invalid choice")
315
+ elif kind in {"multi_choice", "multi-select", "multiselect"}:
316
+ selected, item_warnings = _normalize_multi_choice(raw_value, item)
317
+ values[key] = selected
318
+ warnings.extend(item_warnings)
221
319
  elif kind == "number":
222
320
  try:
223
321
  values[key] = int(raw_value)
@@ -349,11 +447,16 @@ def format_automation_preferences_prompt_block(name_or_path: str) -> str:
349
447
  "\n== STRUCTURED CONTENT PREFERENCES FOR THIS AUTOMATION ==\n"
350
448
  f"{compact}\n"
351
449
  "Morning briefing intent: act like a professional personal assistant preparing the operator for the day. "
352
- "Do not merely list available records; filter, rank, and explain what deserves attention first.\n"
450
+ "The result is a start-of-day preparation, not a settings checklist or a report dump.\n"
451
+ "Do not merely list available records; filter, rank, and explain what deserves attention first. "
452
+ "Score relevance by urgency, change since yesterday, impact, actionability, confidence, and user preference.\n"
353
453
  "Adapt the emphasis from the operator profile, role, recent activity, and context. "
354
454
  "Do not ask the user to choose a user type manually and do not assume a profession unless the context supports it.\n"
455
+ "If automatic relevance is enabled, omit low-value items even when their source is enabled. "
456
+ "Prefer a short Top 3, important changes, commitments, risks, and practical next actions.\n"
355
457
  "Use these preferences to decide what to include, omit, and emphasize. "
356
- "Disabled/unavailable data sources must not be invented; news and weather require verified collected data.\n"
458
+ "Disabled/unavailable data sources must not be invented; news and weather require verified collected data. "
459
+ "Relevant public context should be included only when it helps the operator understand the day, their work, their location, or a declared interest.\n"
357
460
  )
358
461
 
359
462
 
@@ -10,10 +10,18 @@ from typing import Any
10
10
 
11
11
 
12
12
  AUTOMATION_ITEM_ES = {
13
+ "auto_relevance": (
14
+ "Relevancia automática",
15
+ "NEXO decide qué merece aparecer usando urgencia, cambios, impacto, confianza y si hay una acción útil.",
16
+ ),
13
17
  "priorities": (
14
18
  "Prioridades",
15
19
  "Incluye las cosas más importantes del día: lo urgente, lo atrasado y lo que conviene resolver primero.",
16
20
  ),
21
+ "changes_since_yesterday": (
22
+ "Cambios importantes",
23
+ "Incluye novedades recientes, cambios desde ayer y asuntos que se han movido mientras no estabas.",
24
+ ),
17
25
  "agenda": (
18
26
  "Agenda",
19
27
  "Añade citas, eventos o bloques de calendario que NEXO conozca para que sepas qué viene hoy.",
@@ -38,13 +46,29 @@ AUTOMATION_ITEM_ES = {
38
46
  "Bloqueos y riesgos",
39
47
  "Señala bloqueos, riesgos o asuntos que pueden frenarte si no los atiendes.",
40
48
  ),
49
+ "next_actions": (
50
+ "Siguientes acciones",
51
+ "Cierra el resumen con pasos prácticos: qué mirar primero, qué puede esperar y qué decisión falta.",
52
+ ),
41
53
  "internal_refs": (
42
54
  "Referencias internas",
43
55
  "Incluye referencias internas útiles. Déjalo desactivado si prefieres un resumen más limpio.",
44
56
  ),
45
57
  "news": (
46
- "Noticias",
47
- "Añade titulares públicos. Si internet o la fuente de noticias falla, NEXO seguirá preparando el resumen.",
58
+ "Actualidad relevante",
59
+ "Añade contexto público solo si es actual, verificable y útil para tu día, trabajo, ubicación o intereses.",
60
+ ),
61
+ "news_interests": (
62
+ "Temas de actualidad",
63
+ "Temas que NEXO debe vigilar. Usa automático para que los infiera por tu contexto y hábitos.",
64
+ ),
65
+ "excluded_topics": (
66
+ "Temas a evitar",
67
+ "Temas que NEXO debe evitar salvo que sean directamente relevantes para tu trabajo o seguridad.",
68
+ ),
69
+ "why_shown": (
70
+ "Explicar por qué",
71
+ "Añade una frase breve explicando por qué una noticia o dato externo entra en el resumen.",
48
72
  ),
49
73
  "weather": (
50
74
  "Tiempo",
@@ -69,6 +93,48 @@ AUTOMATION_ITEM_ES = {
69
93
  }
70
94
 
71
95
 
96
+ AUTOMATION_OPTION_ALIASES_ES = {
97
+ "news_interests": {
98
+ "automatic": ["automatico", "automático", "auto", "nexo decide", "por defecto"],
99
+ "business": ["empresa", "negocio", "negocios", "empresas", "economia", "economía"],
100
+ "technology": ["tecnologia", "tecnología", "ia", "inteligencia artificial", "software"],
101
+ "finance": ["finanzas", "financiero", "mercados", "bolsa"],
102
+ "local": ["local", "zona", "ciudad", "mallorca", "ubicacion", "ubicación"],
103
+ "health": ["salud", "sanidad", "medicina", "medico", "médico"],
104
+ "legal": ["legal", "leyes", "normativa", "justicia"],
105
+ "education": ["educacion", "educación", "formacion", "formación", "estudios"],
106
+ "real_estate": ["inmobiliario", "vivienda", "arquitectura", "urbanismo"],
107
+ "science": ["ciencia", "investigacion", "investigación", "innovacion", "innovación"],
108
+ "culture": ["cultura", "sociedad"],
109
+ "sports": ["deportes", "deporte"],
110
+ },
111
+ "excluded_topics": {
112
+ "politics": ["politica", "política", "partidos", "elecciones"],
113
+ "sports": ["deportes", "deporte", "futbol", "fútbol"],
114
+ "celebrity": ["famosos", "celebridades", "prensa rosa", "television", "televisión"],
115
+ "crime": ["sucesos", "crimen", "delitos"],
116
+ "crypto": ["cripto", "crypto", "bitcoin", "ethereum"],
117
+ "market_noise": ["ruido de mercado", "bolsa diaria", "mercados diarios"],
118
+ },
119
+ "length": {
120
+ "short": ["corto", "breve"],
121
+ "normal": ["normal", "medio"],
122
+ "detailed": ["detallado", "detalle", "mas detalle", "más detalle"],
123
+ },
124
+ "tone": {
125
+ "direct": ["directo"],
126
+ "warm": ["cercano", "calido", "cálido"],
127
+ "executive": ["ejecutivo", "directivo"],
128
+ "personal": ["personal"],
129
+ },
130
+ "format": {
131
+ "sections": ["secciones"],
132
+ "bullets": ["puntos", "lista", "bullets"],
133
+ "narrative": ["narrativo", "texto"],
134
+ },
135
+ }
136
+
137
+
72
138
  def _fold(value: str) -> str:
73
139
  return re.sub(r"\s+", " ", str(value or "").strip().lower())
74
140
 
@@ -124,11 +190,43 @@ def _coerce_value(value: Any, entry: dict[str, Any]) -> Any:
124
190
  raw = str(value).strip().lower()
125
191
  return raw in {"1", "true", "yes", "on", "si", "sí", "activar", "enabled"}
126
192
  options = [str(option.get("value") if isinstance(option, dict) else option) for option in entry.get("options") or []]
193
+ aliases = entry.get("option_aliases") if isinstance(entry.get("option_aliases"), dict) else {}
194
+ alias_map: dict[str, str] = {}
195
+ for option in options:
196
+ alias_map[_fold(option)] = option
197
+ for alias in list((aliases.get(option) if isinstance(aliases.get(option), list) else []) or []):
198
+ alias_map[_fold(alias)] = option
199
+ if kind in {"multi_choice", "multi-select", "multiselect"}:
200
+ if isinstance(value, (list, tuple, set)):
201
+ raw_items = list(value)
202
+ else:
203
+ raw_items = [part.strip() for part in str(value or "").replace(";", ",").split(",")]
204
+ selected: list[str] = []
205
+ invalid: list[str] = []
206
+ for raw_item in raw_items:
207
+ text = str(raw_item or "").strip()
208
+ if not text:
209
+ continue
210
+ mapped = alias_map.get(_fold(text), text if text in options else "")
211
+ if not mapped:
212
+ invalid.append(text)
213
+ continue
214
+ if mapped not in selected:
215
+ selected.append(mapped)
216
+ if invalid:
217
+ raise ValueError(f"Invalid value '{invalid[0]}'. Valid values: {', '.join(options)}")
218
+ exclusive = [str(v) for v in list(entry.get("exclusive_options") or [])]
219
+ for exclusive_value in exclusive:
220
+ if exclusive_value in selected and len(selected) > 1:
221
+ selected = [exclusive_value]
222
+ break
223
+ return selected
127
224
  if options:
128
225
  raw = str(value).strip()
129
- if raw not in options:
226
+ mapped = alias_map.get(_fold(raw), raw if raw in options else "")
227
+ if mapped not in options:
130
228
  raise ValueError(f"Invalid value '{raw}'. Valid values: {', '.join(options)}")
131
- return raw
229
+ return mapped
132
230
  return value
133
231
 
134
232
 
@@ -192,6 +290,7 @@ def _automation_entries(*, include_values: bool, locale: str) -> list[dict[str,
192
290
  "aliases": [
193
291
  f"morning-agent.{item_id}",
194
292
  f"resumen.{item_id}",
293
+ f"morning.{item_id}",
195
294
  str(item.get("label") or ""),
196
295
  item_id.replace("_", " "),
197
296
  label,
@@ -202,6 +301,8 @@ def _automation_entries(*, include_values: bool, locale: str) -> list[dict[str,
202
301
  "help": help_text,
203
302
  "type": str(item.get("type") or "text"),
204
303
  "options": list(item.get("options") or []),
304
+ "exclusive_options": list(item.get("exclusive_options") or []),
305
+ "option_aliases": AUTOMATION_OPTION_ALIASES_ES.get(item_id, {}) if locale == "es" else {},
205
306
  "writable": not bool(item.get("disabled")),
206
307
  "storage": "personal_scripts.metadata.automation_preferences",
207
308
  "path": item_id,
@@ -38,6 +38,7 @@ import signal
38
38
  import subprocess
39
39
  import sys
40
40
  import tempfile
41
+ import unicodedata
41
42
  import urllib.parse
42
43
  import urllib.request
43
44
  import xml.etree.ElementTree as ET
@@ -87,7 +88,42 @@ MAX_DIARY_ITEMS = 6
87
88
  MORNING_BRIEFING_STALE_HOURS = 12
88
89
  _ACTIVE_CLAIM: dict[str, str] = {}
89
90
  HTTP_TIMEOUT = 7
90
- DEFAULT_NEWS_RSS_URL = "https://news.google.com/rss?hl=es&gl=ES&ceid=ES:es"
91
+ NEWS_MAX_HEADLINES = 8
92
+ NEWS_MAX_FEEDS = 5
93
+ NEWS_INTEREST_QUERY_ES = {
94
+ "business": "empresa economia negocios",
95
+ "technology": "tecnologia inteligencia artificial software",
96
+ "finance": "finanzas economia mercados",
97
+ "local": "actualidad local",
98
+ "health": "salud sanidad medicina",
99
+ "legal": "legal justicia normativa",
100
+ "education": "educacion universidad formacion",
101
+ "real_estate": "vivienda inmobiliario urbanismo",
102
+ "science": "ciencia investigacion innovacion",
103
+ "culture": "cultura sociedad",
104
+ "sports": "deportes",
105
+ }
106
+ NEWS_INTEREST_QUERY_EN = {
107
+ "business": "business economy companies",
108
+ "technology": "technology artificial intelligence software",
109
+ "finance": "finance economy markets",
110
+ "local": "local news",
111
+ "health": "health medicine healthcare",
112
+ "legal": "law regulation justice",
113
+ "education": "education university training",
114
+ "real_estate": "housing real estate urban planning",
115
+ "science": "science research innovation",
116
+ "culture": "culture society",
117
+ "sports": "sports",
118
+ }
119
+ NEWS_EXCLUDED_KEYWORDS = {
120
+ "politics": ["politica", "politics", "eleccion", "election", "partido", "congreso", "senado", "parliament"],
121
+ "sports": ["deporte", "sports", "futbol", "football", "liga", "tenis", "basket", "baloncesto"],
122
+ "celebrity": ["famos", "celebrity", "celebrit", "television", "tv", "influencer"],
123
+ "crime": ["crimen", "crime", "asesin", "homicid", "robo", "suceso", "detenido", "arrest"],
124
+ "crypto": ["crypto", "cripto", "bitcoin", "ethereum", "blockchain", "nft"],
125
+ "market_noise": ["bolsa", "stock", "stocks", "market", "mercado", "wall street", "ibex", "nasdaq"],
126
+ }
91
127
 
92
128
 
93
129
  def log(message: str) -> None:
@@ -654,31 +690,177 @@ def _collect_weather(profile: dict) -> dict:
654
690
  return {"available": False, "error": str(exc)[:240]}
655
691
 
656
692
 
657
- def _collect_news(profile: dict) -> dict:
693
+ def _fold_match(value: object) -> str:
694
+ normalized = unicodedata.normalize("NFKD", str(value or ""))
695
+ asciiish = "".join(ch for ch in normalized if not unicodedata.combining(ch))
696
+ return asciiish.casefold()
697
+
698
+
699
+ def _preference_list(preferences: dict | None, key: str, default: list[str] | None = None) -> list[str]:
700
+ values = preferences if isinstance(preferences, dict) else {}
701
+ raw = values.get(key)
702
+ if isinstance(raw, (list, tuple, set)):
703
+ items = [str(item or "").strip() for item in raw]
704
+ elif raw:
705
+ items = [part.strip() for part in str(raw).replace(";", ",").split(",")]
706
+ else:
707
+ items = list(default or [])
708
+ result: list[str] = []
709
+ for item in items:
710
+ if item and item not in result:
711
+ result.append(item)
712
+ return result
713
+
714
+
715
+ def _news_locale(profile: dict) -> tuple[str, str]:
716
+ language = str(profile.get("language") or "").strip().lower()
717
+ residence = _fold_match(profile.get("current_residence") or profile.get("location") or "")
718
+ if language.startswith("en"):
719
+ return "en", "US"
720
+ if any(token in residence for token in ["united states", "usa", "eeuu", "estados unidos"]):
721
+ return "en", "US"
722
+ return "es", "ES"
723
+
724
+
725
+ def _news_feed_url(query: str, *, language: str, country: str) -> str:
726
+ lang = "en" if language.startswith("en") else "es"
727
+ params = {
728
+ "hl": lang,
729
+ "gl": country,
730
+ "ceid": f"{country}:{lang}",
731
+ }
732
+ clean_query = str(query or "").strip()
733
+ if clean_query:
734
+ params["q"] = clean_query
735
+ return f"https://news.google.com/rss/search?{urllib.parse.urlencode(params)}"
736
+ return f"https://news.google.com/rss?{urllib.parse.urlencode(params)}"
737
+
738
+
739
+ def _profile_news_terms(profile: dict) -> list[str]:
740
+ role = _fold_match(profile.get("role") or profile.get("profession") or profile.get("job_title") or "")
741
+ residence = str(profile.get("current_residence") or profile.get("location") or "").strip()
742
+ terms: list[str] = []
743
+ role_queries = [
744
+ (["fundador", "founder", "ceo", "directivo", "manager", "business"], "empresa economia tecnologia"),
745
+ (["developer", "programador", "software", "technical", "tecnico", "engineer"], "tecnologia software inteligencia artificial"),
746
+ (["medic", "doctor", "clinica", "health", "sanidad"], "salud sanidad medicina"),
747
+ (["arquitect", "architecture", "inmobili", "real estate"], "vivienda urbanismo arquitectura"),
748
+ (["abog", "law", "legal"], "legal normativa justicia"),
749
+ (["educ", "profesor", "student", "estudiante"], "educacion formacion universidad"),
750
+ (["ventas", "sales", "commercial", "comercial"], "ventas clientes empresa"),
751
+ (["administr", "admin", "office"], "empresa laboral administracion"),
752
+ ]
753
+ for needles, query in role_queries:
754
+ if any(needle in role for needle in needles):
755
+ terms.append(query)
756
+ break
757
+ if residence:
758
+ terms.append(f"{residence} actualidad")
759
+ return terms
760
+
761
+
762
+ def _news_queries(profile: dict, preferences: dict | None) -> tuple[list[dict[str, str]], list[str], list[str]]:
763
+ language, country = _news_locale(profile)
764
+ interests = _preference_list(preferences, "news_interests", ["automatic"])
765
+ excluded = _preference_list(preferences, "excluded_topics", [])
766
+ automatic = not interests or "automatic" in interests
767
+ query_map = NEWS_INTEREST_QUERY_EN if language.startswith("en") else NEWS_INTEREST_QUERY_ES
768
+ queries: list[dict[str, str]] = []
769
+
770
+ if automatic:
771
+ for term in _profile_news_terms(profile):
772
+ queries.append({"interest": "automatic", "query": term})
773
+ fallback = "business technology economy" if language.startswith("en") else "empresa tecnologia economia"
774
+ queries.append({"interest": "automatic", "query": fallback})
775
+
776
+ for interest in interests:
777
+ if interest == "automatic":
778
+ continue
779
+ query = query_map.get(interest)
780
+ if not query:
781
+ continue
782
+ if interest == "local":
783
+ residence = str(profile.get("current_residence") or profile.get("location") or "").strip()
784
+ if residence:
785
+ query = f"{residence} actualidad"
786
+ queries.append({"interest": interest, "query": query})
787
+
788
+ if not queries:
789
+ queries.append({"interest": "automatic", "query": ""})
790
+
791
+ deduped: list[dict[str, str]] = []
792
+ seen: set[str] = set()
793
+ for query in queries:
794
+ key = _fold_match(query.get("query") or "") or "top"
795
+ if key in seen:
796
+ continue
797
+ seen.add(key)
798
+ deduped.append(query)
799
+ if len(deduped) >= NEWS_MAX_FEEDS:
800
+ break
801
+ return deduped, interests or ["automatic"], excluded
802
+
803
+
804
+ def _headline_is_excluded(title: str, excluded_topics: list[str]) -> bool:
805
+ folded = _fold_match(title)
806
+ for topic in excluded_topics:
807
+ for keyword in NEWS_EXCLUDED_KEYWORDS.get(topic, []):
808
+ if _fold_match(keyword) in folded:
809
+ return True
810
+ return False
811
+
812
+
813
+ def _parse_news_feed(xml_text: str, *, interest: str, excluded_topics: list[str]) -> list[dict]:
814
+ root = ET.fromstring(xml_text)
815
+ items: list[dict] = []
816
+ for item in root.findall(".//item"):
817
+ title = _clean_text(item.findtext("title"), limit=220)
818
+ if not title or _headline_is_excluded(title, excluded_topics):
819
+ continue
820
+ link = str(item.findtext("link") or "").strip()
821
+ source = _clean_text(item.findtext("source"), limit=80)
822
+ published = _clean_text(item.findtext("pubDate"), limit=120)
823
+ items.append({
824
+ "title": title,
825
+ "source": source,
826
+ "published": published,
827
+ "url": link[:500],
828
+ "interest": interest,
829
+ })
830
+ return items
831
+
832
+
833
+ def _collect_news(profile: dict, preferences: dict | None = None) -> dict:
658
834
  try:
659
- rss_url = str(profile.get("news_rss_url") or os.environ.get("NEXO_NEWS_RSS_URL") or DEFAULT_NEWS_RSS_URL).strip()
660
- if not rss_url:
661
- return {"available": False, "error": "no_news_source"}
662
- xml_text = _fetch_text_url(rss_url)
663
- root = ET.fromstring(xml_text)
835
+ language, country = _news_locale(profile)
836
+ queries, interests, excluded = _news_queries(profile, preferences)
664
837
  items: list[dict] = []
665
- for item in root.findall(".//item"):
666
- title = _clean_text(item.findtext("title"), limit=220)
667
- link = str(item.findtext("link") or "").strip()
668
- source = _clean_text(item.findtext("source"), limit=80)
669
- published = _clean_text(item.findtext("pubDate"), limit=120)
670
- if title:
671
- items.append({
672
- "title": title,
673
- "source": source,
674
- "published": published,
675
- "url": link[:500],
676
- })
677
- if len(items) >= 6:
838
+ seen_titles: set[str] = set()
839
+ used_queries: list[str] = []
840
+ for query in queries:
841
+ feed_url = _news_feed_url(query.get("query") or "", language=language, country=country)
842
+ xml_text = _fetch_text_url(feed_url)
843
+ used_queries.append(query.get("query") or "top headlines")
844
+ for headline in _parse_news_feed(xml_text, interest=query.get("interest") or "automatic", excluded_topics=excluded):
845
+ key = _fold_match(headline.get("title") or "")
846
+ if not key or key in seen_titles:
847
+ continue
848
+ seen_titles.add(key)
849
+ items.append(headline)
850
+ if len(items) >= NEWS_MAX_HEADLINES:
851
+ break
852
+ if len(items) >= NEWS_MAX_HEADLINES:
678
853
  break
679
854
  return {
680
855
  "available": bool(items),
681
- "source": rss_url,
856
+ "source": "google-news-rss",
857
+ "mode": "relevant_public_context",
858
+ "language": language,
859
+ "country": country,
860
+ "interests": interests,
861
+ "excluded_topics": excluded,
862
+ "queries": used_queries,
863
+ "selection_rule": "Headlines are only useful if they relate to the operator's work, location, explicit interests or broad high-impact context.",
682
864
  "headlines": items,
683
865
  "error": "" if items else "empty_feed",
684
866
  }
@@ -692,7 +874,7 @@ def _collect_external_context(profile: dict, preferences: dict | None) -> dict:
692
874
  if values.get("weather"):
693
875
  external["weather"] = _collect_weather(profile)
694
876
  if values.get("news"):
695
- external["news"] = _collect_news(profile)
877
+ external["news"] = _collect_news(profile, values)
696
878
  return external
697
879
 
698
880
 
@@ -9,7 +9,9 @@ Product intent:
9
9
  - Think like a professional personal assistant or chief of staff: filter noise, rank priorities, connect related items, and make the day feel prepared.
10
10
  - Adapt the emphasis to the operator's real context: administrative work needs tasks, deadlines, email movement and missing inputs; executives need decisions, risks, money, people and blocked outcomes; technical users need incidents, deployments, regressions and dependencies; commercial users need leads, customers and follow-ups; regulated or clinical users need careful wording, pending actions and factual status only.
11
11
  - Do not ask the operator to choose a user type. Infer the useful angle from the structured context, profile fields, recent activity and explicit preferences. If the context is thin, stay general and practical.
12
- - Include news and weather only when verified collected data exists in the context. Never fabricate public news, forecasts, calendar events, emails or professional advice.
12
+ - Include news and weather only when verified collected data exists in the context. Public headlines are not a generic news block: include them only when they relate to the operator's work, location, declared interests, or broad high-impact context worth knowing before starting the day.
13
+ - When external context is included, make it useful in one sentence. Do not mention RSS feeds, internal URLs, settings schemas, or source plumbing.
14
+ - If the structured preferences include automatic relevance, actively omit low-value items even when a source is enabled.
13
15
 
14
16
  Hard rules:
15
17
  - Do not invent achievements, blockers, meetings, messages, or external events.
@@ -20,6 +22,14 @@ Hard rules:
20
22
  - Mention operator decisions only when the context actually supports them.
21
23
  - Keep the email concise unless structured preferences ask for more detail: roughly 180-350 words.
22
24
  - Use short sections and bullets when useful.
25
+
26
+ Recommended structure:
27
+ - Opening: one short sentence with the state of the day.
28
+ - Top priorities: the 1-3 things most worth attention.
29
+ - Changes and commitments: only what moved, is due, or affects today's choices.
30
+ - Risks or blockers: only if there is something to prevent, unblock, or decide.
31
+ - Day context: weather and relevant public context only when verified and useful.
32
+ - Next actions: practical first steps when the context supports them.
23
33
  [[extra_section]]Return ONLY a valid JSON object with this exact shape:
24
34
  {
25
35
  "subject": "string",