nexo-brain 0.1.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.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/bin/create-nexo.js +593 -0
- package/package.json +32 -0
- package/scripts/pre-commit-check.sh +55 -0
- package/src/cognitive.py +1224 -0
- package/src/db.py +2283 -0
- package/src/hooks/caffeinate-guard.sh +8 -0
- package/src/hooks/capture-session.sh +19 -0
- package/src/hooks/session-start.sh +27 -0
- package/src/hooks/session-stop.sh +11 -0
- package/src/plugin_loader.py +136 -0
- package/src/plugins/__init__.py +0 -0
- package/src/plugins/agents.py +52 -0
- package/src/plugins/backup.py +103 -0
- package/src/plugins/cognitive_memory.py +305 -0
- package/src/plugins/entities.py +61 -0
- package/src/plugins/episodic_memory.py +391 -0
- package/src/plugins/evolution.py +113 -0
- package/src/plugins/guard.py +346 -0
- package/src/plugins/preferences.py +47 -0
- package/src/scripts/nexo-auto-update.py +213 -0
- package/src/scripts/nexo-catchup.py +179 -0
- package/src/scripts/nexo-cognitive-decay.py +82 -0
- package/src/scripts/nexo-daily-self-audit.py +532 -0
- package/src/scripts/nexo-postmortem-consolidator.py +594 -0
- package/src/scripts/nexo-sleep.py +762 -0
- package/src/scripts/nexo-synthesis.py +537 -0
- package/src/server.py +560 -0
- package/src/tools_coordination.py +102 -0
- package/src/tools_credentials.py +64 -0
- package/src/tools_learnings.py +180 -0
- package/src/tools_menu.py +208 -0
- package/src/tools_reminders.py +80 -0
- package/src/tools_reminders_crud.py +157 -0
- package/src/tools_sessions.py +169 -0
- package/src/tools_task_history.py +57 -0
- package/templates/CLAUDE.md.template +89 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Credentials CRUD tools: get, create, update, delete, list."""
|
|
2
|
+
|
|
3
|
+
from db import create_credential, update_credential, delete_credential, get_credential, list_credentials
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def handle_credential_get(service: str, key: str = '') -> str:
|
|
7
|
+
"""Retrieve credential(s) including their values. Use for reading secrets."""
|
|
8
|
+
results = get_credential(service, key if key else None)
|
|
9
|
+
if not results:
|
|
10
|
+
target = f"{service}/{key}" if key else service
|
|
11
|
+
return f"ERROR: No se encontraron credenciales para '{target}'."
|
|
12
|
+
lines = []
|
|
13
|
+
for r in results:
|
|
14
|
+
lines.append(f"CREDENCIAL {r['service']}/{r['key']}:")
|
|
15
|
+
lines.append(f" Valor: {r['value']}")
|
|
16
|
+
notes = r.get("notes") or ""
|
|
17
|
+
lines.append(f" Notas: {notes if notes else '—'}")
|
|
18
|
+
return "\n".join(lines)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle_credential_create(service: str, key: str, value: str, notes: str = '') -> str:
|
|
22
|
+
"""Create a new credential entry."""
|
|
23
|
+
result = create_credential(service, key, value, notes)
|
|
24
|
+
if "error" in result:
|
|
25
|
+
return f"ERROR: {result['error']}"
|
|
26
|
+
return f"Credencial {service}/{key} creada."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def handle_credential_update(service: str, key: str, value: str = '', notes: str = '') -> str:
|
|
30
|
+
"""Update the value and/or notes of an existing credential."""
|
|
31
|
+
result = update_credential(
|
|
32
|
+
service,
|
|
33
|
+
key,
|
|
34
|
+
value if value else None,
|
|
35
|
+
notes if notes else None,
|
|
36
|
+
)
|
|
37
|
+
if "error" in result:
|
|
38
|
+
return f"ERROR: {result['error']}"
|
|
39
|
+
return f"Credencial {service}/{key} actualizada."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def handle_credential_delete(service: str, key: str = '') -> str:
|
|
43
|
+
"""Delete a credential or all credentials for a service."""
|
|
44
|
+
deleted = delete_credential(service, key if key else None)
|
|
45
|
+
if not deleted:
|
|
46
|
+
target = f"{service}/{key}" if key else service
|
|
47
|
+
return f"ERROR: No se encontraron credenciales para '{target}'."
|
|
48
|
+
if key:
|
|
49
|
+
return f"Credencial {service}/{key} eliminada."
|
|
50
|
+
return f"Todas las credenciales de {service} eliminadas."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def handle_credential_list(service: str = '') -> str:
|
|
54
|
+
"""List credential service/key names and notes — values are never shown."""
|
|
55
|
+
results = list_credentials(service if service else None)
|
|
56
|
+
label = service if service else "TODAS"
|
|
57
|
+
if not results:
|
|
58
|
+
return f"CREDENCIALES {label.upper()}: Sin entradas."
|
|
59
|
+
lines = [f"CREDENCIALES {label.upper()} ({len(results)}):"]
|
|
60
|
+
for r in results:
|
|
61
|
+
notes = r.get("notes") or ""
|
|
62
|
+
suffix = f" — {notes}" if notes else ""
|
|
63
|
+
lines.append(f" {r['service']}/{r['key']}{suffix}")
|
|
64
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Learnings CRUD tools: add, search, update, delete, list."""
|
|
2
|
+
|
|
3
|
+
from db import (create_learning, update_learning, delete_learning, search_learnings,
|
|
4
|
+
list_learnings, find_similar_learnings, get_db, now_epoch)
|
|
5
|
+
|
|
6
|
+
VALID_CATEGORIES = {
|
|
7
|
+
"nexo-ops", "infrastructure", "security", "brain-engine",
|
|
8
|
+
"api", "database", "frontend", "backend", "devops", "general"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def handle_learning_add(category: str, title: str, content: str, reasoning: str = '',
|
|
13
|
+
prevention: str = '', applies_to: str = '', review_days: int = 30) -> str:
|
|
14
|
+
"""Add a new learning entry to the specified category.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
category: One of the valid categories
|
|
18
|
+
title: Short title for the learning
|
|
19
|
+
content: Full description of what was learned
|
|
20
|
+
reasoning: WHY this matters — what led to discovering this, what was the context
|
|
21
|
+
prevention: Concrete rule/check that prevents repeating this mistake
|
|
22
|
+
applies_to: Files, systems, or areas this learning applies to
|
|
23
|
+
review_days: Days until this learning should be reviewed again
|
|
24
|
+
"""
|
|
25
|
+
if category not in VALID_CATEGORIES:
|
|
26
|
+
valid = ", ".join(sorted(VALID_CATEGORIES))
|
|
27
|
+
return f"ERROR: Category '{category}' invalid. Valid: {valid}"
|
|
28
|
+
result = create_learning(
|
|
29
|
+
category, title, content, reasoning=reasoning
|
|
30
|
+
)
|
|
31
|
+
if "error" in result:
|
|
32
|
+
return f"ERROR: {result['error']}"
|
|
33
|
+
if prevention or applies_to or review_days > 0:
|
|
34
|
+
conn = get_db()
|
|
35
|
+
conn.execute(
|
|
36
|
+
"UPDATE learnings SET prevention = ?, applies_to = ?, status = COALESCE(status, 'active'), "
|
|
37
|
+
"review_due_at = ?, updated_at = ? WHERE id = ?",
|
|
38
|
+
(prevention, applies_to, now_epoch() + (max(1, int(review_days)) * 86400), now_epoch(), result["id"])
|
|
39
|
+
)
|
|
40
|
+
conn.commit()
|
|
41
|
+
result = conn.execute("SELECT * FROM learnings WHERE id = ?", (result["id"],)).fetchone()
|
|
42
|
+
result = dict(result)
|
|
43
|
+
|
|
44
|
+
# Cognitive ingest — embed learning for semantic search
|
|
45
|
+
new_id = result["id"]
|
|
46
|
+
try:
|
|
47
|
+
import cognitive
|
|
48
|
+
cognitive.ingest(f"{title}: {content}", "learning", f"L{new_id}", title, category)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# Similarity check — detect repeated errors
|
|
53
|
+
matches = find_similar_learnings(new_id, title, content, category)
|
|
54
|
+
repetition_msg = ""
|
|
55
|
+
if matches:
|
|
56
|
+
conn = get_db()
|
|
57
|
+
for original_id, similarity in matches:
|
|
58
|
+
conn.execute(
|
|
59
|
+
"INSERT INTO error_repetitions (new_learning_id, original_learning_id, similarity, area) VALUES (?,?,?,?)",
|
|
60
|
+
(new_id, original_id, similarity, category)
|
|
61
|
+
)
|
|
62
|
+
conn.commit()
|
|
63
|
+
repetition_msg = f"\n⚠️ REPETITION WARNING: Similar to {len(matches)} existing learning(s): " + \
|
|
64
|
+
", ".join(f"#{m[0]} ({m[1]:.0%})" for m in matches[:3])
|
|
65
|
+
|
|
66
|
+
meta = []
|
|
67
|
+
if prevention:
|
|
68
|
+
meta.append("with prevention")
|
|
69
|
+
if applies_to:
|
|
70
|
+
meta.append(f"applies_to={applies_to}")
|
|
71
|
+
meta_str = f" ({', '.join(meta)})" if meta else ""
|
|
72
|
+
return f"Learning #{result['id']} added in {category}: {title}{meta_str}{repetition_msg}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def handle_learning_search(query: str, category: str = '') -> str:
|
|
76
|
+
"""Search learnings by query string, optionally filtered by category."""
|
|
77
|
+
results = search_learnings(query, category if category else None)
|
|
78
|
+
if not results:
|
|
79
|
+
return f"No results for '{query}'."
|
|
80
|
+
lines = [f"RESULTS ({len(results)}):"]
|
|
81
|
+
for r in results:
|
|
82
|
+
snippet = r["content"][:100] + "..." if len(r["content"]) > 100 else r["content"]
|
|
83
|
+
status = r.get("status", "active")
|
|
84
|
+
review_due = r.get("review_due_at")
|
|
85
|
+
review_note = f" | review_due={review_due:.0f}" if isinstance(review_due, (int, float)) and review_due else ""
|
|
86
|
+
lines.append(f" #{r['id']} [{r['category']}] [{status}] {r['title']}{review_note}")
|
|
87
|
+
lines.append(f" {snippet}")
|
|
88
|
+
if r.get("prevention"):
|
|
89
|
+
lines.append(f" Prevention: {r['prevention'][:100]}")
|
|
90
|
+
|
|
91
|
+
# Passive rehearsal — strengthen matching cognitive memories
|
|
92
|
+
try:
|
|
93
|
+
import cognitive
|
|
94
|
+
for r in results[:5]:
|
|
95
|
+
cognitive.rehearse_by_content(f"{r.get('title', '')} {r.get('content', '')[:200]}")
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def handle_learning_update(id: int, title: str = '', content: str = '', category: str = '',
|
|
103
|
+
reasoning: str = '', prevention: str = '', applies_to: str = '',
|
|
104
|
+
status: str = '', review_days: int = 0) -> str:
|
|
105
|
+
"""Update an existing learning, including review metadata."""
|
|
106
|
+
kwargs = {}
|
|
107
|
+
if title:
|
|
108
|
+
kwargs["title"] = title
|
|
109
|
+
if content:
|
|
110
|
+
kwargs["content"] = content
|
|
111
|
+
if category:
|
|
112
|
+
if category not in VALID_CATEGORIES:
|
|
113
|
+
valid = ", ".join(sorted(VALID_CATEGORIES))
|
|
114
|
+
return f"ERROR: Category '{category}' invalid. Valid: {valid}"
|
|
115
|
+
kwargs["category"] = category
|
|
116
|
+
if reasoning:
|
|
117
|
+
kwargs["reasoning"] = reasoning
|
|
118
|
+
if prevention:
|
|
119
|
+
kwargs["prevention"] = prevention
|
|
120
|
+
if applies_to:
|
|
121
|
+
kwargs["applies_to"] = applies_to
|
|
122
|
+
if status:
|
|
123
|
+
kwargs["status"] = status
|
|
124
|
+
if review_days > 0:
|
|
125
|
+
kwargs["review_days"] = review_days
|
|
126
|
+
if not kwargs:
|
|
127
|
+
return "ERROR: Nothing to update. Provide new fields."
|
|
128
|
+
basic_kwargs = {k: v for k, v in kwargs.items() if k in {"title", "content", "category", "reasoning"}}
|
|
129
|
+
result = update_learning(id, **basic_kwargs)
|
|
130
|
+
if "error" in result:
|
|
131
|
+
return f"ERROR: {result['error']}"
|
|
132
|
+
extra_updates = {}
|
|
133
|
+
if prevention:
|
|
134
|
+
extra_updates["prevention"] = prevention
|
|
135
|
+
if applies_to:
|
|
136
|
+
extra_updates["applies_to"] = applies_to
|
|
137
|
+
if status:
|
|
138
|
+
extra_updates["status"] = status
|
|
139
|
+
if review_days > 0:
|
|
140
|
+
extra_updates["review_due_at"] = now_epoch() + (max(1, int(review_days)) * 86400)
|
|
141
|
+
if extra_updates:
|
|
142
|
+
extra_updates["updated_at"] = now_epoch()
|
|
143
|
+
set_clause = ", ".join(f"{k} = ?" for k in extra_updates)
|
|
144
|
+
values = list(extra_updates.values()) + [id]
|
|
145
|
+
conn = get_db()
|
|
146
|
+
conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", values)
|
|
147
|
+
conn.commit()
|
|
148
|
+
return f"Learning #{id} updated."
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def handle_learning_delete(id: int) -> str:
|
|
152
|
+
"""Delete a learning entry by ID."""
|
|
153
|
+
deleted = delete_learning(id)
|
|
154
|
+
if not deleted:
|
|
155
|
+
return f"ERROR: Learning #{id} not found."
|
|
156
|
+
return f"Learning #{id} deleted."
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def handle_learning_list(category: str = '') -> str:
|
|
160
|
+
"""List all learnings, grouped by category if no filter given."""
|
|
161
|
+
results = list_learnings(category if category else None)
|
|
162
|
+
if not results:
|
|
163
|
+
label = category if category else "ALL"
|
|
164
|
+
return f"LEARNINGS {label} (0): No entries."
|
|
165
|
+
|
|
166
|
+
if category:
|
|
167
|
+
label = category.upper()
|
|
168
|
+
lines = [f"LEARNINGS {label} ({len(results)}):"]
|
|
169
|
+
for r in results:
|
|
170
|
+
lines.append(f" #{r['id']} [{r.get('status','active')}] {r['title']}")
|
|
171
|
+
else:
|
|
172
|
+
lines = [f"LEARNINGS ALL ({len(results)}):"]
|
|
173
|
+
current_cat = None
|
|
174
|
+
for r in results:
|
|
175
|
+
if r["category"] != current_cat:
|
|
176
|
+
current_cat = r["category"]
|
|
177
|
+
lines.append(f"\n [{current_cat.upper()}]")
|
|
178
|
+
lines.append(f" #{r['id']} [{r.get('status','active')}] {r['title']}")
|
|
179
|
+
|
|
180
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Menu generator — NEXO operations center."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tools_sessions import handle_status
|
|
10
|
+
from tools_reminders import handle_reminders
|
|
11
|
+
from db import get_db
|
|
12
|
+
|
|
13
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_date_str() -> str:
|
|
17
|
+
"""Get formatted current date and time."""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["date", "+%A %d %B %Y, %H:%M"],
|
|
21
|
+
capture_output=True, text=True,
|
|
22
|
+
env={"PATH": "/usr/bin:/bin"}
|
|
23
|
+
)
|
|
24
|
+
return result.stdout.strip()
|
|
25
|
+
except Exception:
|
|
26
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MENU_ITEMS = [
|
|
30
|
+
("Projects", [
|
|
31
|
+
("1", "Project Status - Review active projects"),
|
|
32
|
+
("2", "Infrastructure - Server health check"),
|
|
33
|
+
]),
|
|
34
|
+
("Advertising", [
|
|
35
|
+
("3", "Google Ads - Manage campaigns"),
|
|
36
|
+
("4", "Meta Ads - Manage Facebook/Instagram"),
|
|
37
|
+
]),
|
|
38
|
+
("Analytics & Monitoring", [
|
|
39
|
+
("5", "Google Analytics - Review web analytics"),
|
|
40
|
+
("6", "Email Review - Review inboxes"),
|
|
41
|
+
]),
|
|
42
|
+
("Maintenance", [
|
|
43
|
+
("7", "Backup - Check backup status"),
|
|
44
|
+
("8", "Memory Review - Review pending learnings/decisions"),
|
|
45
|
+
]),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_dashboard_alerts() -> list[dict]:
|
|
50
|
+
"""Run proactive dashboard and return alerts."""
|
|
51
|
+
try:
|
|
52
|
+
script = NEXO_HOME / "scripts" / "nexo-proactive-dashboard.py"
|
|
53
|
+
if not script.exists():
|
|
54
|
+
return []
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
[sys.executable, str(script), "--json"],
|
|
57
|
+
capture_output=True, text=True, timeout=10
|
|
58
|
+
)
|
|
59
|
+
if result.stdout.strip():
|
|
60
|
+
return json.loads(result.stdout)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_memory_review_summary() -> dict:
|
|
67
|
+
"""Return counts of due memory reviews."""
|
|
68
|
+
try:
|
|
69
|
+
conn = get_db()
|
|
70
|
+
now_epoch = datetime.now().timestamp()
|
|
71
|
+
now_iso = datetime.now().isoformat(timespec="seconds")
|
|
72
|
+
due_learnings = conn.execute(
|
|
73
|
+
"SELECT COUNT(*) FROM learnings WHERE review_due_at IS NOT NULL AND status != 'superseded' AND review_due_at <= ?",
|
|
74
|
+
(now_epoch,)
|
|
75
|
+
).fetchone()[0]
|
|
76
|
+
due_decisions = conn.execute(
|
|
77
|
+
"SELECT COUNT(*) FROM decisions WHERE review_due_at IS NOT NULL AND status != 'reviewed' AND review_due_at <= ?",
|
|
78
|
+
(now_iso,)
|
|
79
|
+
).fetchone()[0]
|
|
80
|
+
return {
|
|
81
|
+
"learnings": due_learnings,
|
|
82
|
+
"decisions": due_decisions,
|
|
83
|
+
"total": due_learnings + due_decisions,
|
|
84
|
+
}
|
|
85
|
+
except Exception:
|
|
86
|
+
return {"learnings": 0, "decisions": 0, "total": 0}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def handle_menu() -> str:
|
|
90
|
+
"""Generate the full operations menu with alerts."""
|
|
91
|
+
date_str = _get_date_str()
|
|
92
|
+
W = 56 # inner width
|
|
93
|
+
|
|
94
|
+
lines = []
|
|
95
|
+
lines.append("╔" + "═" * W + "╗")
|
|
96
|
+
lines.append("║" + "NEXO — OPERATIONS CENTER".center(W) + "║")
|
|
97
|
+
lines.append("║" + date_str.center(W) + "║")
|
|
98
|
+
lines.append("╠" + "═" * W + "╣")
|
|
99
|
+
|
|
100
|
+
# Proactive dashboard alerts
|
|
101
|
+
dashboard_alerts = _get_dashboard_alerts()
|
|
102
|
+
memory_reviews = _get_memory_review_summary()
|
|
103
|
+
due = handle_reminders("due")
|
|
104
|
+
has_alerts = dashboard_alerts or memory_reviews["total"] > 0 or (due and "Sin recordatorios" not in due)
|
|
105
|
+
|
|
106
|
+
if has_alerts:
|
|
107
|
+
lines.append("║" + " PROACTIVE ALERTS".ljust(W) + "║")
|
|
108
|
+
lines.append("╠" + "═" * W + "╣")
|
|
109
|
+
|
|
110
|
+
if dashboard_alerts:
|
|
111
|
+
for alert in dashboard_alerts[:10]:
|
|
112
|
+
sev = alert.get("severity", "low")
|
|
113
|
+
icon = {"high": "!!!", "medium": " ! ", "low": " . "}.get(sev, " . ")
|
|
114
|
+
text = alert.get("title", "")[:W - 8]
|
|
115
|
+
lines.append("║" + f" {icon} {text}".ljust(W) + "║")
|
|
116
|
+
if len(dashboard_alerts) > 10:
|
|
117
|
+
more = len(dashboard_alerts) - 10
|
|
118
|
+
lines.append("║" + f" ... and {more} more alerts".ljust(W) + "║")
|
|
119
|
+
|
|
120
|
+
if memory_reviews["total"] > 0:
|
|
121
|
+
text = (
|
|
122
|
+
f"MEMORY: {memory_reviews['total']} reviews pending "
|
|
123
|
+
f"({memory_reviews['decisions']} decisions, {memory_reviews['learnings']} learnings)"
|
|
124
|
+
)[:W - 4]
|
|
125
|
+
lines.append("║" + f" ! {text}".ljust(W) + "║")
|
|
126
|
+
|
|
127
|
+
if due and "Sin recordatorios" not in due:
|
|
128
|
+
for reminder_line in due.split("\n"):
|
|
129
|
+
if reminder_line.strip():
|
|
130
|
+
truncated = reminder_line[:W - 2]
|
|
131
|
+
lines.append("║" + f" {truncated}".ljust(W) + "║")
|
|
132
|
+
|
|
133
|
+
lines.append("╠" + "═" * W + "╣")
|
|
134
|
+
|
|
135
|
+
# Menu categories
|
|
136
|
+
for category, items in MENU_ITEMS:
|
|
137
|
+
lines.append("║" + f" {category.upper()}".ljust(W) + "║")
|
|
138
|
+
lines.append("║" + "─" * W + "║")
|
|
139
|
+
for num, desc in items:
|
|
140
|
+
entry = f" {num:>3}. {desc}"
|
|
141
|
+
lines.append("║" + entry.ljust(W) + "║")
|
|
142
|
+
lines.append("╠" + "═" * W + "╣")
|
|
143
|
+
|
|
144
|
+
# Backlog: ideas, future projects, undated tasks
|
|
145
|
+
try:
|
|
146
|
+
conn = get_db()
|
|
147
|
+
cutoff = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
|
|
148
|
+
no_date = conn.execute(
|
|
149
|
+
"SELECT id, description, category FROM reminders WHERE status LIKE 'PENDIENTE%' AND (date IS NULL OR date='') ORDER BY category, id"
|
|
150
|
+
).fetchall()
|
|
151
|
+
future = conn.execute(
|
|
152
|
+
"SELECT id, description, date, category FROM reminders WHERE status LIKE 'PENDIENTE%' AND date > ? ORDER BY date",
|
|
153
|
+
(cutoff,)
|
|
154
|
+
).fetchall()
|
|
155
|
+
nf_no_date = conn.execute(
|
|
156
|
+
"SELECT id, description FROM followups WHERE status NOT LIKE 'COMPLETADO%' AND (date IS NULL OR date='') ORDER BY id"
|
|
157
|
+
).fetchall()
|
|
158
|
+
|
|
159
|
+
if no_date or future or nf_no_date:
|
|
160
|
+
lines.append("║" + " BACKLOG / IDEAS / FUTURE".ljust(W) + "║")
|
|
161
|
+
lines.append("║" + "─" * W + "║")
|
|
162
|
+
|
|
163
|
+
if no_date:
|
|
164
|
+
by_cat = {}
|
|
165
|
+
for r in no_date:
|
|
166
|
+
cat = (r["category"] or "general").capitalize()
|
|
167
|
+
by_cat.setdefault(cat, []).append(r)
|
|
168
|
+
for cat, items in by_cat.items():
|
|
169
|
+
lines.append("║" + f" [{cat}]".ljust(W) + "║")
|
|
170
|
+
for r in items:
|
|
171
|
+
short = r["description"][:W - 10]
|
|
172
|
+
lines.append("║" + f" {r['id']}: {short}".ljust(W) + "║")
|
|
173
|
+
|
|
174
|
+
if future:
|
|
175
|
+
lines.append("║" + f" [Scheduled]".ljust(W) + "║")
|
|
176
|
+
for r in future:
|
|
177
|
+
short = r["description"][:W - 18]
|
|
178
|
+
lines.append("║" + f" {r['id']} ({r['date']}): {short}".ljust(W) + "║")
|
|
179
|
+
|
|
180
|
+
if nf_no_date:
|
|
181
|
+
lines.append("║" + f" [Pending followups]".ljust(W) + "║")
|
|
182
|
+
for r in nf_no_date:
|
|
183
|
+
short = r["description"][:W - 12]
|
|
184
|
+
lines.append("║" + f" {r['id']}: {short}".ljust(W) + "║")
|
|
185
|
+
|
|
186
|
+
lines.append("╠" + "═" * W + "╣")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
lines.append("║" + f" ! Backlog error: {e}".ljust(W) + "║")
|
|
189
|
+
lines.append("╠" + "═" * W + "╣")
|
|
190
|
+
|
|
191
|
+
# Active sessions
|
|
192
|
+
sessions = handle_status()
|
|
193
|
+
if "Sin sesiones" not in sessions:
|
|
194
|
+
lines.append("║" + " ACTIVE SESSIONS".ljust(W) + "║")
|
|
195
|
+
lines.append("║" + "─" * W + "║")
|
|
196
|
+
for s_line in sessions.split("\n"):
|
|
197
|
+
if s_line.strip() and "SESIONES ACTIVAS" not in s_line:
|
|
198
|
+
truncated = s_line[:W - 2]
|
|
199
|
+
lines.append("║" + f" {truncated}".ljust(W) + "║")
|
|
200
|
+
lines.append("╠" + "═" * W + "╣")
|
|
201
|
+
|
|
202
|
+
# Replace last ╠═╣ with bottom border
|
|
203
|
+
if lines[-1].startswith("╠"):
|
|
204
|
+
lines[-1] = "╚" + "═" * W + "╝"
|
|
205
|
+
else:
|
|
206
|
+
lines.append("╚" + "═" * W + "╝")
|
|
207
|
+
|
|
208
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Reminders and followups reader — reads from SQLite database."""
|
|
2
|
+
|
|
3
|
+
from db import get_reminders, get_followups
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _is_due(date_str: str) -> bool:
|
|
8
|
+
"""Check if a date string is today or in the past."""
|
|
9
|
+
if not date_str:
|
|
10
|
+
return False
|
|
11
|
+
try:
|
|
12
|
+
d = date.fromisoformat(date_str.strip()[:10])
|
|
13
|
+
return d <= date.today()
|
|
14
|
+
except (ValueError, IndexError):
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handle_reminders(filter_type: str = "due") -> str:
|
|
19
|
+
"""Read reminders and followups from SQLite, return relevant ones.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
filter_type: 'due' (vencidos/hoy), 'all' (todos activos), 'followups' (solo followups)
|
|
23
|
+
"""
|
|
24
|
+
parts = []
|
|
25
|
+
|
|
26
|
+
if filter_type in ("due", "all"):
|
|
27
|
+
r = _format_reminders(filter_type)
|
|
28
|
+
if r:
|
|
29
|
+
parts.append(r)
|
|
30
|
+
|
|
31
|
+
if filter_type in ("due", "all", "followups"):
|
|
32
|
+
f = _format_followups(filter_type)
|
|
33
|
+
if f:
|
|
34
|
+
parts.append(f)
|
|
35
|
+
|
|
36
|
+
result = "\n\n".join(parts)
|
|
37
|
+
return result if result else "Sin recordatorios pendientes."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_reminders(filter_type: str) -> str:
|
|
41
|
+
"""Format reminders from database."""
|
|
42
|
+
rows = get_reminders(filter_type)
|
|
43
|
+
if not rows:
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
lines = ["RECORDATORIOS:"]
|
|
47
|
+
for r in rows:
|
|
48
|
+
rid = r.get("id", "?")
|
|
49
|
+
fecha = r.get("date") or ""
|
|
50
|
+
desc = r.get("description", "")
|
|
51
|
+
status = r.get("status", "")
|
|
52
|
+
desc = desc.replace("**", "")
|
|
53
|
+
due_marker = " [VENCIDO]" if _is_due(fecha) else ""
|
|
54
|
+
fecha_display = f"({fecha})" if fecha else "(—)"
|
|
55
|
+
lines.append(f" {rid} {fecha_display}{due_marker} — {desc[:120]}")
|
|
56
|
+
if "RECURRENTE" in status.upper():
|
|
57
|
+
lines.append(f" Estado: {status}")
|
|
58
|
+
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _format_followups(filter_type: str) -> str:
|
|
63
|
+
"""Format followups from database."""
|
|
64
|
+
rows = get_followups(filter_type)
|
|
65
|
+
if not rows:
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
lines = ["FOLLOWUPS NEXO:"]
|
|
69
|
+
for r in rows:
|
|
70
|
+
nfid = r.get("id", "?")
|
|
71
|
+
fecha = r.get("date") or ""
|
|
72
|
+
desc = r.get("description", "")
|
|
73
|
+
desc = desc.replace("**", "")
|
|
74
|
+
due_marker = " [VENCIDO]" if _is_due(fecha) else ""
|
|
75
|
+
fecha_display = f"({fecha})" if fecha else "(—)"
|
|
76
|
+
rec = r.get("recurrence") or ""
|
|
77
|
+
rec_tag = f" [♻️ {rec}]" if rec else ""
|
|
78
|
+
lines.append(f" {nfid} {fecha_display}{due_marker}{rec_tag} — {desc[:120]}")
|
|
79
|
+
|
|
80
|
+
return "\n".join(lines)
|