nexo-brain 7.28.0 → 7.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ """Unified preference catalog for agent-facing read/explain/set operations."""
4
+
5
+ import copy
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ AUTOMATION_ITEM_ES = {
13
+ "priorities": (
14
+ "Prioridades",
15
+ "Incluye las cosas más importantes del día: lo urgente, lo atrasado y lo que conviene resolver primero.",
16
+ ),
17
+ "agenda": (
18
+ "Agenda",
19
+ "Añade citas, eventos o bloques de calendario que NEXO conozca para que sepas qué viene hoy.",
20
+ ),
21
+ "reminders": (
22
+ "Recordatorios y tareas",
23
+ "Muestra recordatorios y tareas pendientes que pueden requerir acción durante el día.",
24
+ ),
25
+ "followups": (
26
+ "Seguimientos",
27
+ "Incluye asuntos abiertos que NEXO debe empujar hasta que haya respuesta o decisión.",
28
+ ),
29
+ "decisions": (
30
+ "Decisiones recientes",
31
+ "Resume decisiones recientes guardadas para no perder cambios importantes de criterio.",
32
+ ),
33
+ "email_activity": (
34
+ "Emails enviados recientes",
35
+ "Añade emails enviados recientemente para dar contexto de conversaciones activas.",
36
+ ),
37
+ "blockers": (
38
+ "Bloqueos y riesgos",
39
+ "Señala bloqueos, riesgos o asuntos que pueden frenarte si no los atiendes.",
40
+ ),
41
+ "internal_refs": (
42
+ "Referencias internas",
43
+ "Incluye referencias internas útiles. Déjalo desactivado si prefieres un resumen más limpio.",
44
+ ),
45
+ "news": (
46
+ "Noticias",
47
+ "Añade titulares públicos. Si internet o la fuente de noticias falla, NEXO seguirá preparando el resumen.",
48
+ ),
49
+ "weather": (
50
+ "Tiempo",
51
+ "Añade el tiempo usando tu ubicación guardada en el perfil o en Desktop.",
52
+ ),
53
+ "length": (
54
+ "Longitud",
55
+ "Controla cuánto detalle quieres: corto, normal o más completo.",
56
+ ),
57
+ "tone": (
58
+ "Tono",
59
+ "Cambia cómo escribe el resumen: directo, cercano, ejecutivo o personal.",
60
+ ),
61
+ "format": (
62
+ "Formato",
63
+ "Elige la estructura visual del resumen: secciones, lista de puntos o texto narrativo.",
64
+ ),
65
+ "quiet_days": (
66
+ "Días tranquilos",
67
+ "Decide qué hacer cuando no hay novedades importantes.",
68
+ ),
69
+ }
70
+
71
+
72
+ def _fold(value: str) -> str:
73
+ return re.sub(r"\s+", " ", str(value or "").strip().lower())
74
+
75
+
76
+ def _label(value: Any, locale: str = "es") -> str:
77
+ if isinstance(value, dict):
78
+ return str(value.get(locale) or value.get("es") or value.get("en") or "").strip()
79
+ return str(value or "").strip()
80
+
81
+
82
+ def _read_json(path: Path) -> dict[str, Any]:
83
+ try:
84
+ if path.is_file():
85
+ payload = json.loads(path.read_text())
86
+ if isinstance(payload, dict):
87
+ return payload
88
+ except Exception:
89
+ pass
90
+ return {}
91
+
92
+
93
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
96
+
97
+
98
+ def _get_path(payload: dict[str, Any], dotted: str) -> Any:
99
+ current: Any = payload
100
+ for part in dotted.split("."):
101
+ if not isinstance(current, dict):
102
+ return None
103
+ current = current.get(part)
104
+ return current
105
+
106
+
107
+ def _set_path(payload: dict[str, Any], dotted: str, value: Any) -> dict[str, Any]:
108
+ next_payload = copy.deepcopy(payload)
109
+ current = next_payload
110
+ parts = dotted.split(".")
111
+ for part in parts[:-1]:
112
+ child = current.get(part)
113
+ if not isinstance(child, dict):
114
+ child = {}
115
+ current[part] = child
116
+ current = child
117
+ current[parts[-1]] = value
118
+ return next_payload
119
+
120
+
121
+ def _coerce_value(value: Any, entry: dict[str, Any]) -> Any:
122
+ kind = str(entry.get("type") or "").lower()
123
+ if kind in {"boolean", "toggle"}:
124
+ raw = str(value).strip().lower()
125
+ return raw in {"1", "true", "yes", "on", "si", "sí", "activar", "enabled"}
126
+ options = [str(option.get("value") if isinstance(option, dict) else option) for option in entry.get("options") or []]
127
+ if options:
128
+ raw = str(value).strip()
129
+ if raw not in options:
130
+ raise ValueError(f"Invalid value '{raw}'. Valid values: {', '.join(options)}")
131
+ return raw
132
+ return value
133
+
134
+
135
+ def _brain_paths() -> dict[str, Path]:
136
+ import paths
137
+
138
+ brain = paths.brain_dir()
139
+ return {
140
+ "calibration.json": brain / "calibration.json",
141
+ "profile.json": brain / "profile.json",
142
+ }
143
+
144
+
145
+ def _brain_schema_entries(*, include_values: bool, locale: str) -> list[dict[str, Any]]:
146
+ from desktop_bridge import _schema_fields
147
+
148
+ paths = _brain_paths()
149
+ cache = {name: _read_json(path) for name, path in paths.items()}
150
+ entries: list[dict[str, Any]] = []
151
+ for field in _schema_fields():
152
+ dotted = str(field.get("path") or field.get("writes") or "").strip()
153
+ file_name = str(field.get("file") or "").strip()
154
+ if not dotted or file_name not in paths:
155
+ continue
156
+ entry = {
157
+ "id": dotted,
158
+ "aliases": [field.get("id") or "", dotted.replace("user.", "assistant.")],
159
+ "section": "assistant" if dotted.startswith("user.") else "profile",
160
+ "label": _label(field.get("label") or field.get("prompt") or dotted, locale),
161
+ "help": _label(field.get("hint") or "", locale),
162
+ "type": str(field.get("type") or "text"),
163
+ "options": field.get("options") or [],
164
+ "writable": True,
165
+ "storage": file_name,
166
+ "path": dotted,
167
+ }
168
+ if include_values:
169
+ entry["current_value"] = _get_path(cache[file_name], dotted)
170
+ entries.append(entry)
171
+ return entries
172
+
173
+
174
+ def _automation_entries(*, include_values: bool, locale: str) -> list[dict[str, Any]]:
175
+ from automation_controls import get_core_automation_schedule_state
176
+ from automation_preferences import get_automation_preferences
177
+
178
+ contract = get_automation_preferences("morning-agent")
179
+ schema = contract.get("schema") or {}
180
+ values = ((contract.get("preferences") or {}).get("values") or {}) if include_values else {}
181
+ entries: list[dict[str, Any]] = []
182
+ for group in list(schema.get("groups") or []):
183
+ for item in list(group.get("items") or []):
184
+ item_id = str(item.get("id") or "").strip()
185
+ if not item_id:
186
+ continue
187
+ localized = AUTOMATION_ITEM_ES.get(item_id) if locale == "es" else None
188
+ label = (localized[0] if localized else str(item.get("label") or item_id))
189
+ help_text = (localized[1] if localized else str(item.get("help") or item.get("disabled_reason") or ""))
190
+ entry = {
191
+ "id": f"automation.morning-agent.{item_id}",
192
+ "aliases": [
193
+ f"morning-agent.{item_id}",
194
+ f"resumen.{item_id}",
195
+ str(item.get("label") or ""),
196
+ item_id.replace("_", " "),
197
+ label,
198
+ ],
199
+ "section": "automation.morning-agent",
200
+ "group": str(group.get("id") or ""),
201
+ "label": label,
202
+ "help": help_text,
203
+ "type": str(item.get("type") or "text"),
204
+ "options": list(item.get("options") or []),
205
+ "writable": not bool(item.get("disabled")),
206
+ "storage": "personal_scripts.metadata.automation_preferences",
207
+ "path": item_id,
208
+ }
209
+ if include_values:
210
+ entry["current_value"] = values.get(item_id, item.get("default"))
211
+ entries.append(entry)
212
+
213
+ schedule_state = get_core_automation_schedule_state("morning-agent")
214
+ schedule_entry = {
215
+ "id": "automation.morning-agent.schedule",
216
+ "aliases": ["resumen horario", "resumen frecuencia", "morning-agent schedule"],
217
+ "section": "automation.morning-agent",
218
+ "group": "schedule",
219
+ "label": "Horario del resumen de la mañana",
220
+ "help": "Hora y días en que se ejecuta el resumen. Acepta valores como 07:00 Mon-Fri o {\"daily_at\":\"07:00\",\"weekdays\":\"Tue,Sat\"}.",
221
+ "type": "calendar",
222
+ "writable": True,
223
+ "storage": "schedule.json.core_automation_overrides",
224
+ "path": "morning-agent.schedule",
225
+ }
226
+ if include_values:
227
+ schedule_entry["current_value"] = {
228
+ "label": schedule_state.get("effective_schedule_label"),
229
+ "schedule": schedule_state.get("schedule"),
230
+ "source": schedule_state.get("schedule_source"),
231
+ }
232
+ entries.append(schedule_entry)
233
+ return entries
234
+
235
+
236
+ def _client_entries(*, include_values: bool) -> list[dict[str, Any]]:
237
+ from client_preferences import load_client_preferences
238
+ from resonance_map import TIERS, _load_user_default_resonance
239
+
240
+ prefs = load_client_preferences()
241
+ provider_runtime = prefs.get("provider_runtime") if isinstance(prefs.get("provider_runtime"), dict) else {}
242
+ definitions = [
243
+ ("client.default_terminal_client", "Cliente de chat por defecto", "codex o claude_code", ["codex", "claude_code"]),
244
+ ("client.automation_enabled", "Automatizaciones activadas", "Activa o pausa rutinas de fondo.", []),
245
+ ("client.automation_backend", "Motor de automatizaciones", "Backend usado por automatizaciones.", ["codex", "claude_code", "none"]),
246
+ ("client.selected_chat_provider", "Proveedor de chat", "Proveedor seleccionado para conversaciones.", ["openai", "anthropic"]),
247
+ ("client.default_resonance", "Nivel de razonamiento", "Nivel bajo/medio/alto/maximo usado por NEXO.", list(TIERS)),
248
+ ]
249
+ current = {
250
+ "client.default_terminal_client": prefs.get("default_terminal_client"),
251
+ "client.automation_enabled": prefs.get("automation_enabled"),
252
+ "client.automation_backend": prefs.get("automation_backend"),
253
+ "client.selected_chat_provider": provider_runtime.get("selected_chat_provider"),
254
+ "client.default_resonance": _load_user_default_resonance() or prefs.get("default_resonance"),
255
+ }
256
+ entries = []
257
+ for pref_id, label, help_text, options in definitions:
258
+ entry = {
259
+ "id": pref_id,
260
+ "aliases": [pref_id.replace("client.", "")],
261
+ "section": "client",
262
+ "label": label,
263
+ "help": help_text,
264
+ "type": "boolean" if pref_id.endswith("automation_enabled") else "choice",
265
+ "options": options,
266
+ "writable": True,
267
+ "storage": "schedule.json",
268
+ "path": pref_id,
269
+ }
270
+ if include_values:
271
+ entry["current_value"] = current.get(pref_id)
272
+ entries.append(entry)
273
+ return entries
274
+
275
+
276
+ def _desktop_app_entries() -> list[dict[str, Any]]:
277
+ return [{
278
+ "id": "desktop.app",
279
+ "aliases": ["app settings", "preferencias de app", "desktop preferences"],
280
+ "section": "desktop.app",
281
+ "label": "Preferencias visuales y locales de Desktop",
282
+ "help": "Estas opciones viven en Desktop y deben cambiarse desde Desktop para disparar guardado, mirrors y refrescos de UI.",
283
+ "type": "catalog",
284
+ "writable": False,
285
+ "storage": "desktop.app-settings.json",
286
+ "path": "app.*",
287
+ }]
288
+
289
+
290
+ def build_preference_catalog(*, include_values: bool = False, query: str | None = None, locale: str = "es") -> dict[str, Any]:
291
+ entries = [
292
+ *_brain_schema_entries(include_values=include_values, locale=locale),
293
+ *_automation_entries(include_values=include_values, locale=locale),
294
+ *_client_entries(include_values=include_values),
295
+ *_desktop_app_entries(),
296
+ ]
297
+ needle = _fold(query or "")
298
+ if needle:
299
+ entries = [
300
+ entry for entry in entries
301
+ if needle in _fold(" ".join([
302
+ str(entry.get("id") or ""),
303
+ str(entry.get("label") or ""),
304
+ str(entry.get("help") or ""),
305
+ " ".join(str(alias) for alias in entry.get("aliases") or []),
306
+ ]))
307
+ ]
308
+ return {"ok": True, "count": len(entries), "preferences": entries}
309
+
310
+
311
+ def find_preference(id_or_alias: str, *, include_values: bool = True) -> dict[str, Any] | None:
312
+ target = _fold(id_or_alias)
313
+ if not target:
314
+ return None
315
+ catalog = build_preference_catalog(include_values=include_values)
316
+ for entry in catalog["preferences"]:
317
+ keys = [entry.get("id"), *(entry.get("aliases") or [])]
318
+ if any(_fold(key) == target for key in keys):
319
+ return entry
320
+ matches = build_preference_catalog(include_values=include_values, query=id_or_alias)["preferences"]
321
+ return matches[0] if len(matches) == 1 else None
322
+
323
+
324
+ def explain_preference(id_or_alias: str) -> dict[str, Any]:
325
+ entry = find_preference(id_or_alias, include_values=True)
326
+ if not entry:
327
+ return {"ok": False, "error": f"Preference not found: {id_or_alias}"}
328
+ return {"ok": True, "preference": entry}
329
+
330
+
331
+ def _set_brain_entry(entry: dict[str, Any], value: Any, *, dry_run: bool) -> dict[str, Any]:
332
+ file_name = str(entry.get("storage") or "")
333
+ dotted = str(entry.get("path") or "")
334
+ paths = _brain_paths()
335
+ path = paths.get(file_name)
336
+ if not path:
337
+ return {"ok": False, "error": f"Unsupported storage: {file_name}"}
338
+ current = _read_json(path)
339
+ coerced = _coerce_value(value, entry)
340
+ next_payload = _set_path(current, dotted, coerced)
341
+ if not dry_run:
342
+ _write_json(path, next_payload)
343
+ return {"ok": True, "dry_run": dry_run, "id": entry["id"], "value": coerced, "storage": file_name}
344
+
345
+
346
+ def _set_automation_entry(entry: dict[str, Any], value: Any, *, dry_run: bool) -> dict[str, Any]:
347
+ from automation_preferences import get_automation_preferences, set_automation_preferences
348
+
349
+ key = str(entry.get("path") or "")
350
+ contract = get_automation_preferences("morning-agent")
351
+ values = dict(((contract.get("preferences") or {}).get("values") or {}))
352
+ coerced = _coerce_value(value, entry)
353
+ values[key] = coerced
354
+ if not dry_run:
355
+ result = set_automation_preferences("morning-agent", {"values": values})
356
+ if not result.get("ok"):
357
+ return result
358
+ return {"ok": True, "dry_run": dry_run, "id": entry["id"], "value": coerced}
359
+
360
+
361
+ def _parse_schedule_value(value: Any) -> tuple[str, Any]:
362
+ if isinstance(value, dict):
363
+ return str(value.get("daily_at") or value.get("time") or "").strip(), value.get("weekdays")
364
+ text = str(value or "").strip()
365
+ if text.startswith("{"):
366
+ payload = json.loads(text)
367
+ if not isinstance(payload, dict):
368
+ raise ValueError("Schedule JSON must be an object.")
369
+ return _parse_schedule_value(payload)
370
+ match = re.match(r"^(\d{1,2}:\d{2})(?:\s+(.+))?$", text)
371
+ if not match:
372
+ raise ValueError("Use HH:MM, optionally followed by days, e.g. 07:00 Mon-Fri.")
373
+ return match.group(1), (match.group(2) or "")
374
+
375
+
376
+ def _set_schedule_entry(entry: dict[str, Any], value: Any, *, dry_run: bool) -> dict[str, Any]:
377
+ from script_registry import set_automation_schedule
378
+
379
+ daily_at, weekdays = _parse_schedule_value(value)
380
+ if not daily_at:
381
+ return {"ok": False, "error": "daily_at is required."}
382
+ if dry_run:
383
+ return {"ok": True, "dry_run": True, "id": entry["id"], "daily_at": daily_at, "weekdays": weekdays}
384
+ result = set_automation_schedule("morning-agent", daily_at=daily_at, weekdays=weekdays)
385
+ result["dry_run"] = False
386
+ return result
387
+
388
+
389
+ def _set_client_entry(entry: dict[str, Any], value: Any, *, dry_run: bool) -> dict[str, Any]:
390
+ from client_preferences import load_client_preferences, save_client_preferences
391
+
392
+ pref_id = str(entry.get("id") or "")
393
+ coerced = _coerce_value(value, entry)
394
+ if dry_run:
395
+ return {"ok": True, "dry_run": True, "id": pref_id, "value": coerced}
396
+ if pref_id == "client.default_terminal_client":
397
+ save_client_preferences(default_terminal_client=str(coerced))
398
+ elif pref_id == "client.automation_enabled":
399
+ save_client_preferences(automation_enabled=bool(coerced), automation_user_override=True)
400
+ elif pref_id == "client.automation_backend":
401
+ save_client_preferences(automation_backend=str(coerced), automation_user_override=True)
402
+ elif pref_id == "client.selected_chat_provider":
403
+ save_client_preferences(selected_chat_provider=str(coerced))
404
+ elif pref_id == "client.default_resonance":
405
+ save_client_preferences(default_resonance=str(coerced))
406
+ try:
407
+ from cli import _write_calibration_default_resonance
408
+
409
+ _write_calibration_default_resonance(str(coerced))
410
+ except Exception:
411
+ pass
412
+ else:
413
+ return {"ok": False, "error": f"Unsupported client preference: {pref_id}"}
414
+ return {"ok": True, "dry_run": False, "id": pref_id, "value": coerced, "preferences": load_client_preferences()}
415
+
416
+
417
+ def set_preference(id_or_alias: str, value: Any, *, dry_run: bool = False) -> dict[str, Any]:
418
+ entry = find_preference(id_or_alias, include_values=False)
419
+ if not entry:
420
+ return {"ok": False, "error": f"Preference not found: {id_or_alias}"}
421
+ if not entry.get("writable"):
422
+ return {"ok": False, "error": "This preference is read-only from Brain; use Desktop for app.* preferences.", "preference": entry}
423
+ pref_id = str(entry.get("id") or "")
424
+ if pref_id == "automation.morning-agent.schedule":
425
+ return _set_schedule_entry(entry, value, dry_run=dry_run)
426
+ if pref_id.startswith("automation.morning-agent."):
427
+ return _set_automation_entry(entry, value, dry_run=dry_run)
428
+ if pref_id.startswith("client."):
429
+ return _set_client_entry(entry, value, dry_run=dry_run)
430
+ return _set_brain_entry(entry, value, dry_run=dry_run)
431
+
432
+
433
+ __all__ = [
434
+ "build_preference_catalog",
435
+ "explain_preference",
436
+ "find_preference",
437
+ "set_preference",
438
+ ]
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "tiers": {
3
3
  "maximo": {
4
- "claude_code": { "model": "claude-opus-4-7[1m]", "effort": "max" },
4
+ "claude_code": { "model": "claude-opus-4-8", "effort": "max" },
5
5
  "codex": { "model": "gpt-5.5", "effort": "xhigh" }
6
6
  },
7
7
  "alto": {
8
- "claude_code": { "model": "claude-opus-4-7[1m]", "effort": "xhigh" },
8
+ "claude_code": { "model": "claude-opus-4-8", "effort": "xhigh" },
9
9
  "codex": { "model": "gpt-5.5", "effort": "high" }
10
10
  },
11
11
  "medio": {
12
- "claude_code": { "model": "claude-opus-4-7[1m]", "effort": "high" },
12
+ "claude_code": { "model": "claude-opus-4-8", "effort": "high" },
13
13
  "codex": { "model": "gpt-5.5", "effort": "medium" }
14
14
  },
15
15
  "bajo": {
16
- "claude_code": { "model": "claude-opus-4-7[1m]", "effort": "medium" },
16
+ "claude_code": { "model": "claude-opus-4-8", "effort": "medium" },
17
17
  "codex": { "model": "gpt-5.5", "effort": "low" }
18
18
  },
19
19
  "muy_bajo": {
@@ -935,6 +935,7 @@ def list_scripts(include_core: bool = False) -> list[dict]:
935
935
  or bool(contract.get("toggleable_core"))
936
936
  )
937
937
  entry["supports_extra_instructions"] = bool(contract.get("supports_extra_instructions"))
938
+ entry["supports_automation_preferences"] = bool(contract.get("supports_automation_preferences"))
938
939
  entry["operator_extra_instructions"] = str(metadata.get("operator_extra_instructions") or "")
939
940
  entry["runtime_contract"] = contract
940
941
  entry["available"] = bool(contract.get("available", True))
@@ -945,6 +946,8 @@ def list_scripts(include_core: bool = False) -> list[dict]:
945
946
  entry["schedule_type"] = str(contract.get("schedule_type") or "")
946
947
  entry["schedule_source"] = str(contract.get("schedule_source") or "")
947
948
  entry["effective_schedule_label"] = str(contract.get("effective_schedule_label") or "")
949
+ entry["schedule"] = contract.get("schedule")
950
+ entry["default_schedule"] = contract.get("default_schedule")
948
951
  entry["interval_seconds"] = int(contract.get("interval_seconds", 0) or 0)
949
952
  entry["default_interval_seconds"] = int(contract.get("default_interval_seconds", 0) or 0)
950
953
  entry["minimum_interval_seconds"] = int(contract.get("minimum_interval_seconds", 0) or 0)
@@ -2930,11 +2933,26 @@ def set_automation_instructions(name_or_path: str, instructions: str) -> dict:
2930
2933
  return set_script_extra_instructions(name_or_path, instructions)
2931
2934
 
2932
2935
 
2936
+ def get_automation_preference_contract(name_or_path: str) -> dict:
2937
+ """Return schema + current structured preferences for a product automation."""
2938
+ from automation_preferences import get_automation_preferences
2939
+
2940
+ return get_automation_preferences(name_or_path)
2941
+
2942
+
2943
+ def set_automation_preference_contract(name_or_path: str, payload: dict) -> dict:
2944
+ """Persist structured preferences without touching extra instructions."""
2945
+ from automation_preferences import set_automation_preferences
2946
+
2947
+ return set_automation_preferences(name_or_path, payload)
2948
+
2949
+
2933
2950
  def set_script_schedule_override(
2934
2951
  name_or_path: str,
2935
2952
  *,
2936
2953
  interval_seconds: int | None = None,
2937
2954
  daily_at: str | None = None,
2955
+ weekdays=None,
2938
2956
  clear: bool = False,
2939
2957
  ) -> dict:
2940
2958
  from automation_controls import set_core_automation_schedule
@@ -2949,6 +2967,7 @@ def set_script_schedule_override(
2949
2967
  script.get("name", name_or_path),
2950
2968
  interval_seconds=interval_seconds,
2951
2969
  daily_at=daily_at,
2970
+ weekdays=weekdays,
2952
2971
  clear=clear,
2953
2972
  )
2954
2973
 
@@ -2958,6 +2977,7 @@ def set_automation_schedule(
2958
2977
  *,
2959
2978
  interval_seconds: int | None = None,
2960
2979
  daily_at: str | None = None,
2980
+ weekdays=None,
2961
2981
  clear: bool = False,
2962
2982
  ) -> dict:
2963
2983
  """Stable contract wrapper for automation cadence overrides."""
@@ -2965,6 +2985,7 @@ def set_automation_schedule(
2965
2985
  name_or_path,
2966
2986
  interval_seconds=interval_seconds,
2967
2987
  daily_at=daily_at,
2988
+ weekdays=weekdays,
2968
2989
  clear=clear,
2969
2990
  )
2970
2991