nexo-brain 7.30.4 → 7.30.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 +3 -1
- package/package.json +1 -1
- package/src/auto_update.py +15 -0
- package/src/automation_preferences.py +116 -13
- package/src/deep_sleep_retention.py +320 -0
- package/src/preference_catalog.py +105 -4
- package/src/scripts/deep-sleep/retention.py +15 -0
- package/src/scripts/nexo-deep-sleep.sh +11 -0
- package/src/scripts/nexo-morning-agent.py +204 -22
- package/src/scripts/nexo-sleep.py +14 -0
- package/templates/core-prompts/morning-agent.md +11 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.6",
|
|
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.
|
|
21
|
+
Version `7.30.6` is the current packaged-runtime line. Patch release over v7.30.5 - Deep Sleep now rotates its operational artifacts and logs automatically, keeping historical installs bounded without touching local-context memory.
|
|
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.
|
|
3
|
+
"version": "7.30.6",
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -2275,6 +2275,21 @@ def _heal_deep_sleep_runtime(dest: Path = NEXO_HOME) -> list[str]:
|
|
|
2275
2275
|
except OSError:
|
|
2276
2276
|
pass
|
|
2277
2277
|
|
|
2278
|
+
# (5) Bound Deep Sleep operational artifacts for existing installs. Older
|
|
2279
|
+
# versions accumulated DB backups and large context dumps indefinitely; this
|
|
2280
|
+
# apply path runs on update so users do not have to wait for the next night.
|
|
2281
|
+
try:
|
|
2282
|
+
from deep_sleep_retention import prune_deep_sleep_runtime
|
|
2283
|
+
|
|
2284
|
+
report = prune_deep_sleep_runtime(nexo_home=dest, apply=True)
|
|
2285
|
+
deleted = int(report.get("deleted_count") or 0)
|
|
2286
|
+
rotated = int(report.get("logs_rotated") or 0)
|
|
2287
|
+
freed = int(report.get("deleted_bytes") or 0) + int(report.get("log_bytes_trimmed") or 0)
|
|
2288
|
+
if deleted or rotated:
|
|
2289
|
+
actions.append(f"retention:{deleted}-deleted:{rotated}-logs:{freed}-bytes")
|
|
2290
|
+
except Exception as exc:
|
|
2291
|
+
actions.append(f"retention-warning:{exc.__class__.__name__}")
|
|
2292
|
+
|
|
2278
2293
|
return actions
|
|
2279
2294
|
|
|
2280
2295
|
|
|
@@ -14,20 +14,34 @@ SUPPORTED_AUTOMATIONS = {"morning-agent"}
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
17
|
-
"schema_version":
|
|
17
|
+
"schema_version": 2,
|
|
18
18
|
"automation": "morning-agent",
|
|
19
|
-
"title": "Morning
|
|
19
|
+
"title": "Morning preparation",
|
|
20
20
|
"groups": [
|
|
21
21
|
{
|
|
22
22
|
"id": "content",
|
|
23
|
-
"label": "
|
|
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
|
|
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": "
|
|
89
|
+
"id": "next_actions",
|
|
76
90
|
"type": "boolean",
|
|
77
|
-
"label": "
|
|
78
|
-
"default":
|
|
79
|
-
"help": "
|
|
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": "
|
|
96
|
+
"id": "internal_refs",
|
|
83
97
|
"type": "boolean",
|
|
84
|
-
"label": "
|
|
98
|
+
"label": "Internal references",
|
|
85
99
|
"default": False,
|
|
86
|
-
"help": "
|
|
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
|
-
"
|
|
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
|
|
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
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Deep Sleep runtime retention.
|
|
2
|
+
|
|
3
|
+
Keeps Deep Sleep operational artifacts bounded without touching the live memory
|
|
4
|
+
databases or the local-context index. The policy is intentionally conservative:
|
|
5
|
+
old context dumps are only deleted after their run produced a synthesis or agent
|
|
6
|
+
start packet, so failed or incomplete nights remain debuggable.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable
|
|
18
|
+
|
|
19
|
+
DEFAULT_KEEP_DB_BACKUPS = int(os.environ.get("NEXO_DEEP_SLEEP_KEEP_DB_BACKUPS", "3") or "3")
|
|
20
|
+
DEFAULT_KEEP_CONTEXTS = int(os.environ.get("NEXO_DEEP_SLEEP_KEEP_CONTEXTS", "7") or "7")
|
|
21
|
+
DEFAULT_MAX_LOG_BYTES = int(os.environ.get("NEXO_DEEP_SLEEP_MAX_LOG_BYTES", str(1024 * 1024)) or str(1024 * 1024))
|
|
22
|
+
DEFAULT_RETAINED_LOG_BYTES = int(
|
|
23
|
+
os.environ.get("NEXO_DEEP_SLEEP_RETAINED_LOG_BYTES", str(768 * 1024)) or str(768 * 1024)
|
|
24
|
+
)
|
|
25
|
+
DEBUG_TTL_SECONDS = int(os.environ.get("NEXO_DEEP_SLEEP_DEBUG_TTL_SECONDS", str(7 * 86400)) or str(7 * 86400))
|
|
26
|
+
|
|
27
|
+
_DATE_PREFIX_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})(?:[-T]?(\d{6}))?")
|
|
28
|
+
_DATE_CONTEXT_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}(?:[-T]?\d{6})?)-context\.txt$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _default_home() -> Path:
|
|
32
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dedupe_existing(paths: Iterable[Path]) -> list[Path]:
|
|
36
|
+
seen: set[Path] = set()
|
|
37
|
+
result: list[Path] = []
|
|
38
|
+
for path in paths:
|
|
39
|
+
try:
|
|
40
|
+
key = path.resolve()
|
|
41
|
+
except OSError:
|
|
42
|
+
key = path
|
|
43
|
+
if key in seen or not path.exists():
|
|
44
|
+
continue
|
|
45
|
+
seen.add(key)
|
|
46
|
+
result.append(path)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _deep_sleep_dirs(nexo_home: Path) -> list[Path]:
|
|
51
|
+
return _dedupe_existing(
|
|
52
|
+
[
|
|
53
|
+
nexo_home / "runtime" / "operations" / "deep-sleep",
|
|
54
|
+
nexo_home / "operations" / "deep-sleep",
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _log_dirs(nexo_home: Path) -> list[Path]:
|
|
60
|
+
return _dedupe_existing(
|
|
61
|
+
[
|
|
62
|
+
nexo_home / "runtime" / "logs",
|
|
63
|
+
nexo_home / "logs",
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _artifact_sort_key(path: Path) -> tuple[float, str]:
|
|
69
|
+
match = _DATE_PREFIX_RE.match(path.name)
|
|
70
|
+
if match:
|
|
71
|
+
date_part, time_part = match.groups()
|
|
72
|
+
compact = date_part.replace("-", "") + (time_part or "000000")
|
|
73
|
+
try:
|
|
74
|
+
return (float(compact), path.name)
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
return (path.stat().st_mtime, path.name)
|
|
79
|
+
except OSError:
|
|
80
|
+
return (0.0, path.name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _analyzed_marker_exists(deep_sleep_dir: Path, run_id: str) -> bool:
|
|
84
|
+
markers = [
|
|
85
|
+
deep_sleep_dir / f"{run_id}-agent-start-packet.json",
|
|
86
|
+
deep_sleep_dir / f"{run_id}-synthesis.json",
|
|
87
|
+
deep_sleep_dir / f"{run_id}-applied.json",
|
|
88
|
+
deep_sleep_dir / run_id / "synthesis.json",
|
|
89
|
+
]
|
|
90
|
+
return any(path.exists() and path.stat().st_size > 0 for path in markers)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _path_size(path: Path) -> int:
|
|
94
|
+
try:
|
|
95
|
+
if path.is_dir():
|
|
96
|
+
total = 0
|
|
97
|
+
for child in path.rglob("*"):
|
|
98
|
+
try:
|
|
99
|
+
if child.is_file() or child.is_symlink():
|
|
100
|
+
total += child.stat().st_size
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
return total
|
|
104
|
+
return path.stat().st_size
|
|
105
|
+
except OSError:
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _delete_path(path: Path, *, apply: bool) -> tuple[bool, int]:
|
|
110
|
+
size = _path_size(path)
|
|
111
|
+
if not apply:
|
|
112
|
+
return True, size
|
|
113
|
+
try:
|
|
114
|
+
if path.is_dir():
|
|
115
|
+
shutil.rmtree(path)
|
|
116
|
+
else:
|
|
117
|
+
path.unlink()
|
|
118
|
+
return True, size
|
|
119
|
+
except FileNotFoundError:
|
|
120
|
+
return False, 0
|
|
121
|
+
except OSError:
|
|
122
|
+
return False, 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _sidecars(path: Path) -> list[Path]:
|
|
126
|
+
candidates = [Path(str(path) + "-wal"), Path(str(path) + "-shm")]
|
|
127
|
+
return [candidate for candidate in candidates if candidate.exists()]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _record_delete(report: dict, path: Path, *, reason: str, apply: bool) -> None:
|
|
131
|
+
ok, size = _delete_path(path, apply=apply)
|
|
132
|
+
if not ok:
|
|
133
|
+
report["warnings"].append(f"delete-failed:{path}")
|
|
134
|
+
return
|
|
135
|
+
report["deleted_count"] += 1
|
|
136
|
+
report["deleted_bytes"] += size
|
|
137
|
+
report["deleted"].append({"path": str(path), "bytes": size, "reason": reason})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _prune_db_backups(deep_sleep_dir: Path, report: dict, *, keep: int, apply: bool) -> None:
|
|
141
|
+
for family in ("*-backup-nexo.db", "*-backup-cognitive.db"):
|
|
142
|
+
backups = sorted(deep_sleep_dir.glob(family), key=_artifact_sort_key, reverse=True)
|
|
143
|
+
kept = backups[:keep]
|
|
144
|
+
report["kept"].append({"kind": family, "count": len(kept), "root": str(deep_sleep_dir)})
|
|
145
|
+
for backup in backups[keep:]:
|
|
146
|
+
_record_delete(report, backup, reason=f"old-db-backup:{family}", apply=apply)
|
|
147
|
+
for sidecar in _sidecars(backup):
|
|
148
|
+
_record_delete(report, sidecar, reason=f"old-db-backup-sidecar:{family}", apply=apply)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _prune_contexts(deep_sleep_dir: Path, report: dict, *, keep: int, apply: bool) -> None:
|
|
152
|
+
contexts: list[tuple[str, Path]] = []
|
|
153
|
+
for path in deep_sleep_dir.glob("*-context.txt"):
|
|
154
|
+
match = _DATE_CONTEXT_RE.match(path.name)
|
|
155
|
+
if not match:
|
|
156
|
+
continue
|
|
157
|
+
contexts.append((match.group(1), path))
|
|
158
|
+
|
|
159
|
+
contexts.sort(key=lambda item: _artifact_sort_key(item[1]), reverse=True)
|
|
160
|
+
keep_run_ids = {run_id for run_id, _ in contexts[:keep]}
|
|
161
|
+
report["kept"].append({"kind": "context.txt", "count": min(len(contexts), keep), "root": str(deep_sleep_dir)})
|
|
162
|
+
|
|
163
|
+
for run_id, context_file in contexts[keep:]:
|
|
164
|
+
if run_id in keep_run_ids:
|
|
165
|
+
continue
|
|
166
|
+
if not _analyzed_marker_exists(deep_sleep_dir, run_id):
|
|
167
|
+
report["kept"].append({"kind": "unanalyzed-context", "path": str(context_file)})
|
|
168
|
+
continue
|
|
169
|
+
_record_delete(report, context_file, reason="old-analyzed-context", apply=apply)
|
|
170
|
+
date_dir = deep_sleep_dir / run_id
|
|
171
|
+
if date_dir.is_dir():
|
|
172
|
+
_record_delete(report, date_dir, reason="old-analyzed-context-dir", apply=apply)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _prune_debug_scratch(deep_sleep_dir: Path, report: dict, *, now: float, apply: bool) -> None:
|
|
176
|
+
for pattern in ("debug-extract-*.txt", "debug-synthesize-*.txt"):
|
|
177
|
+
for path in deep_sleep_dir.glob(pattern):
|
|
178
|
+
try:
|
|
179
|
+
if now - path.stat().st_mtime <= DEBUG_TTL_SECONDS:
|
|
180
|
+
continue
|
|
181
|
+
except OSError:
|
|
182
|
+
continue
|
|
183
|
+
_record_delete(report, path, reason="old-debug-scratch", apply=apply)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _rotate_log(path: Path, report: dict, *, max_bytes: int, retained_bytes: int, apply: bool) -> None:
|
|
187
|
+
try:
|
|
188
|
+
original_size = path.stat().st_size
|
|
189
|
+
except OSError:
|
|
190
|
+
return
|
|
191
|
+
if original_size <= max_bytes:
|
|
192
|
+
return
|
|
193
|
+
retained_bytes = max(1, min(retained_bytes, max_bytes))
|
|
194
|
+
if not apply:
|
|
195
|
+
report["logs_rotated"] += 1
|
|
196
|
+
report["log_bytes_trimmed"] += max(0, original_size - retained_bytes)
|
|
197
|
+
report["rotated_logs"].append({"path": str(path), "original_bytes": original_size, "dry_run": True})
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
with path.open("rb") as fh:
|
|
202
|
+
if original_size > retained_bytes:
|
|
203
|
+
fh.seek(-retained_bytes, os.SEEK_END)
|
|
204
|
+
tail = fh.read()
|
|
205
|
+
newline = tail.find(b"\n")
|
|
206
|
+
if newline > 0:
|
|
207
|
+
tail = tail[newline + 1 :]
|
|
208
|
+
header = (
|
|
209
|
+
f"[rotated by NEXO Deep Sleep retention; original_bytes={original_size}; "
|
|
210
|
+
f"retained_bytes={len(tail)}]\n"
|
|
211
|
+
).encode("utf-8")
|
|
212
|
+
path.write_bytes(header + tail)
|
|
213
|
+
new_size = path.stat().st_size
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
report["warnings"].append(f"log-rotate-failed:{path}:{exc.__class__.__name__}")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
report["logs_rotated"] += 1
|
|
219
|
+
report["log_bytes_trimmed"] += max(0, original_size - new_size)
|
|
220
|
+
report["rotated_logs"].append({"path": str(path), "original_bytes": original_size, "new_bytes": new_size})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _rotate_logs(nexo_home: Path, report: dict, *, max_bytes: int, retained_bytes: int, apply: bool) -> None:
|
|
224
|
+
names = {
|
|
225
|
+
"deep-sleep.log",
|
|
226
|
+
"deep-sleep-stdout.log",
|
|
227
|
+
"deep-sleep-stderr.log",
|
|
228
|
+
"sleep-stdout.log",
|
|
229
|
+
"sleep-stderr.log",
|
|
230
|
+
"prevent-sleep-stdout.log",
|
|
231
|
+
"prevent-sleep-stderr.log",
|
|
232
|
+
}
|
|
233
|
+
for log_dir in _log_dirs(nexo_home):
|
|
234
|
+
for name in names:
|
|
235
|
+
path = log_dir / name
|
|
236
|
+
if path.is_file():
|
|
237
|
+
_rotate_log(path, report, max_bytes=max_bytes, retained_bytes=retained_bytes, apply=apply)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def prune_deep_sleep_runtime(
|
|
241
|
+
*,
|
|
242
|
+
nexo_home: str | Path | None = None,
|
|
243
|
+
apply: bool = False,
|
|
244
|
+
keep_db_backups: int = DEFAULT_KEEP_DB_BACKUPS,
|
|
245
|
+
keep_contexts: int = DEFAULT_KEEP_CONTEXTS,
|
|
246
|
+
max_log_bytes: int = DEFAULT_MAX_LOG_BYTES,
|
|
247
|
+
retained_log_bytes: int = DEFAULT_RETAINED_LOG_BYTES,
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Apply or plan Deep Sleep retention for a runtime home."""
|
|
250
|
+
home = Path(nexo_home).expanduser() if nexo_home is not None else _default_home()
|
|
251
|
+
keep_db_backups = max(1, int(keep_db_backups))
|
|
252
|
+
keep_contexts = max(1, int(keep_contexts))
|
|
253
|
+
report: dict = {
|
|
254
|
+
"ok": True,
|
|
255
|
+
"apply": bool(apply),
|
|
256
|
+
"nexo_home": str(home),
|
|
257
|
+
"roots": [],
|
|
258
|
+
"deleted_count": 0,
|
|
259
|
+
"deleted_bytes": 0,
|
|
260
|
+
"deleted": [],
|
|
261
|
+
"kept": [],
|
|
262
|
+
"logs_rotated": 0,
|
|
263
|
+
"log_bytes_trimmed": 0,
|
|
264
|
+
"rotated_logs": [],
|
|
265
|
+
"warnings": [],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
now = time.time()
|
|
269
|
+
for deep_sleep_dir in _deep_sleep_dirs(home):
|
|
270
|
+
report["roots"].append(str(deep_sleep_dir))
|
|
271
|
+
_prune_db_backups(deep_sleep_dir, report, keep=keep_db_backups, apply=apply)
|
|
272
|
+
_prune_contexts(deep_sleep_dir, report, keep=keep_contexts, apply=apply)
|
|
273
|
+
_prune_debug_scratch(deep_sleep_dir, report, now=now, apply=apply)
|
|
274
|
+
_rotate_logs(home, report, max_bytes=max_log_bytes, retained_bytes=retained_log_bytes, apply=apply)
|
|
275
|
+
return report
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _print_human(report: dict) -> None:
|
|
279
|
+
mode = "apply" if report.get("apply") else "dry-run"
|
|
280
|
+
print(f"NEXO Deep Sleep retention ({mode})")
|
|
281
|
+
print(f" roots: {len(report.get('roots') or [])}")
|
|
282
|
+
print(f" deleted: {report.get('deleted_count', 0)}")
|
|
283
|
+
print(f" freed/planned: {report.get('deleted_bytes', 0)} bytes")
|
|
284
|
+
print(f" logs_rotated: {report.get('logs_rotated', 0)}")
|
|
285
|
+
print(f" log_bytes_trimmed: {report.get('log_bytes_trimmed', 0)}")
|
|
286
|
+
if report.get("warnings"):
|
|
287
|
+
print(" warnings:")
|
|
288
|
+
for warning in report["warnings"]:
|
|
289
|
+
print(f" - {warning}")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: list[str] | None = None) -> int:
|
|
293
|
+
parser = argparse.ArgumentParser(description="NEXO Deep Sleep retention")
|
|
294
|
+
parser.add_argument("--nexo-home", default=None)
|
|
295
|
+
parser.add_argument("--apply", action="store_true", help="delete/rotate instead of dry-run")
|
|
296
|
+
parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
297
|
+
parser.add_argument("--quiet", action="store_true", help="suppress human output")
|
|
298
|
+
parser.add_argument("--keep-db-backups", type=int, default=DEFAULT_KEEP_DB_BACKUPS)
|
|
299
|
+
parser.add_argument("--keep-contexts", type=int, default=DEFAULT_KEEP_CONTEXTS)
|
|
300
|
+
parser.add_argument("--max-log-bytes", type=int, default=DEFAULT_MAX_LOG_BYTES)
|
|
301
|
+
parser.add_argument("--retained-log-bytes", type=int, default=DEFAULT_RETAINED_LOG_BYTES)
|
|
302
|
+
args = parser.parse_args(argv)
|
|
303
|
+
|
|
304
|
+
report = prune_deep_sleep_runtime(
|
|
305
|
+
nexo_home=args.nexo_home,
|
|
306
|
+
apply=args.apply,
|
|
307
|
+
keep_db_backups=args.keep_db_backups,
|
|
308
|
+
keep_contexts=args.keep_contexts,
|
|
309
|
+
max_log_bytes=args.max_log_bytes,
|
|
310
|
+
retained_log_bytes=args.retained_log_bytes,
|
|
311
|
+
)
|
|
312
|
+
if args.json:
|
|
313
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
314
|
+
elif not args.quiet:
|
|
315
|
+
_print_human(report)
|
|
316
|
+
return 0 if report.get("ok") else 1
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|
|
@@ -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
|
-
"
|
|
47
|
-
"Añade
|
|
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
|
|
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
|
|
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,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SRC_DIR = Path(__file__).resolve().parents[2]
|
|
8
|
+
if str(SRC_DIR) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(SRC_DIR))
|
|
10
|
+
|
|
11
|
+
from deep_sleep_retention import main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
raise SystemExit(main())
|
|
@@ -23,6 +23,14 @@ mkdir -p "$LOG_DIR" "$DEEP_SLEEP_DIR"
|
|
|
23
23
|
|
|
24
24
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_DIR/deep-sleep.log"; }
|
|
25
25
|
|
|
26
|
+
run_retention() {
|
|
27
|
+
if ! python3 "$SCRIPT_DIR/deep-sleep/retention.py" --apply --quiet >> "$LOG_DIR/deep-sleep.log" 2>&1; then
|
|
28
|
+
log "Retention warning: Deep Sleep cleanup failed"
|
|
29
|
+
fi
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
run_retention
|
|
33
|
+
|
|
26
34
|
# Read watermark (last processed timestamp)
|
|
27
35
|
SINCE=""
|
|
28
36
|
if [ -f "$WATERMARK_FILE" ]; then
|
|
@@ -46,6 +54,7 @@ if [ ! -f "$DEEP_SLEEP_DIR/$RUN_ID-context.txt" ]; then
|
|
|
46
54
|
log "No context file generated. Skipping."
|
|
47
55
|
echo "$UNTIL" > "$WATERMARK_FILE"
|
|
48
56
|
log "Watermark updated to $UNTIL (no sessions to process)"
|
|
57
|
+
run_retention
|
|
49
58
|
exit 0
|
|
50
59
|
fi
|
|
51
60
|
|
|
@@ -58,6 +67,7 @@ if [ "$SESSIONS" -eq 0 ]; then
|
|
|
58
67
|
log "No sessions found. Skipping."
|
|
59
68
|
echo "$UNTIL" > "$WATERMARK_FILE"
|
|
60
69
|
log "Watermark updated to $UNTIL (no sessions)"
|
|
70
|
+
run_retention
|
|
61
71
|
exit 0
|
|
62
72
|
fi
|
|
63
73
|
|
|
@@ -96,3 +106,4 @@ fi
|
|
|
96
106
|
echo "$UNTIL" > "$WATERMARK_FILE"
|
|
97
107
|
log "Watermark updated to $UNTIL"
|
|
98
108
|
log "=== Deep Sleep v2 complete (run_id=$RUN_ID) ==="
|
|
109
|
+
run_retention
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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":
|
|
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
|
|
|
@@ -41,6 +41,7 @@ if str(NEXO_CODE) not in sys.path:
|
|
|
41
41
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
42
42
|
from constants import AUTOMATION_SUBPROCESS_TIMEOUT
|
|
43
43
|
from core_prompts import render_core_prompt
|
|
44
|
+
from deep_sleep_retention import prune_deep_sleep_runtime
|
|
44
45
|
import paths
|
|
45
46
|
try:
|
|
46
47
|
from client_preferences import resolve_user_model as _resolve_user_model
|
|
@@ -261,6 +262,9 @@ def stage_a_cleanup() -> dict:
|
|
|
261
262
|
"a5_heartbeat_trimmed": False,
|
|
262
263
|
"a6_reflection_trimmed": False,
|
|
263
264
|
"a7_daemon_logs_deleted": 0,
|
|
265
|
+
"a9_deep_sleep_deleted": 0,
|
|
266
|
+
"a9_deep_sleep_freed_bytes": 0,
|
|
267
|
+
"a9_deep_sleep_logs_rotated": 0,
|
|
264
268
|
}
|
|
265
269
|
|
|
266
270
|
# A1: Delete daily_summaries/*.md >90 days
|
|
@@ -366,6 +370,16 @@ def stage_a_cleanup() -> dict:
|
|
|
366
370
|
except Exception:
|
|
367
371
|
pass
|
|
368
372
|
|
|
373
|
+
# A9: Bound Deep Sleep operational artifacts and logs. This is safe to run
|
|
374
|
+
# daily: incomplete/unanalysed contexts are preserved for retry/debugging.
|
|
375
|
+
try:
|
|
376
|
+
deep_sleep_retention = prune_deep_sleep_runtime(nexo_home=NEXO_HOME, apply=True)
|
|
377
|
+
stats["a9_deep_sleep_deleted"] = int(deep_sleep_retention.get("deleted_count") or 0)
|
|
378
|
+
stats["a9_deep_sleep_freed_bytes"] = int(deep_sleep_retention.get("deleted_bytes") or 0)
|
|
379
|
+
stats["a9_deep_sleep_logs_rotated"] = int(deep_sleep_retention.get("logs_rotated") or 0)
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
stats["a9_deep_sleep_warning"] = exc.__class__.__name__
|
|
382
|
+
|
|
369
383
|
return stats
|
|
370
384
|
|
|
371
385
|
|
|
@@ -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.
|
|
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",
|