+"""The butler's brain: turn an incoming message into an action and a reply.
+
+This module is deliberately transport-agnostic — it knows nothing about
+Telegram. `handle_message` takes text and returns a string reply, so it can be
+unit-tested directly (see tests/test_core.py) and reused by the HTTP layer.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from datetime import datetime, timezone
+
+from .db import Database
+from .llm import LLM
+from .timeparse import parse_date_range, parse_when
+
+# Strong signal that a message is a question, used to correct the occasional
+# small-model misclassification of a question as a note.
+_INTERROGATIVE_RE = re.compile(
+ r"(\?|¿)|^\s*(qu[ée]|cu[áa]l(es)?|cu[áa]ndo|d[óo]nde|ad[óo]nde|qui[ée]n(es)?|"
+ r"c[óo]mo|cu[áa]nto?s?|cu[áa]nta?s?|por\s+qu[ée]|para\s+qu[ée])\b",
+ re.IGNORECASE,
+)
+
+
+def _looks_like_question(text: str) -> bool:
+ return bool(_INTERROGATIVE_RE.search(text.strip()))
+
+
+# Words that indicate a genuine "show me my list" request (vs. a specific
+# question that the model mislabelled as a listing).
+_LIST_WORDS_RE = re.compile(
+ r"\b(notas?|recordatorios?|pendientes?|tareas?|lista)\b", re.IGNORECASE
+)
+
+# --- Delete / undo: handled deterministically, because the small model tends to
+# misclassify "borra el recordatorio 1" as a note. ---
+_DELETE_VERB_RE = re.compile(
+ # b[oó]rra… but NOT "borrador"/"borradores" (draft), which merely contains it.
+ r"\b(b[oó]rra(?!dor)\w*|elimina\w*|quita\w*|suprim\w*|descarta\w*|c[aá]ncela\w*)\b",
+ re.IGNORECASE,
+)
+_UNDO_RE = re.compile(
+ r"\b(deshaz|deshacer|undo|rev(?:ierte|ertir)|vuelve\s+atr[áa]s|"
+ r"anula\s+(?:eso|lo\s+[úu]ltimo|la\s+[úu]ltima)|"
+ r"me\s+(?:he\s+)?equivoqu[ée]|no\s+era\s+eso|c[aá]ncela\s+eso)\b",
+ re.IGNORECASE,
+)
+_REMINDER_WORD_RE = re.compile(
+ r"\b(recordatorios?|avisos?|alarmas?|recu[eé]rd\w*)\b", re.IGNORECASE
+)
+_NOTE_WORD_RE = re.compile(r"\b(notas?|apuntes?|memos?)\b", re.IGNORECASE)
+_LAST_RE = re.compile(r"\b([úu]ltim[oa]s?|recient\w*)\b", re.IGNORECASE)
+# Don't treat "recuérdame borra…" as a delete — it's a reminder to delete later.
+_REMINDER_PREFIX_RE = re.compile(r"^\s*(recu[eé]rda\w*|av[ií]sa\w*)", re.IGNORECASE)
+
+_ID_WORDS = {
+ "uno": 1, "una": 1, "dos": 2, "tres": 3, "cuatro": 4, "cinco": 5, "seis": 6,
+ "siete": 7, "ocho": 8, "nueve": 9, "diez": 10, "once": 11, "doce": 12,
+}
+
+
+def _extract_id(text: str) -> int | None:
+ m = re.search(r"\b(\d{1,6})\b", text)
+ if m:
+ return int(m.group(1))
+ for word, n in _ID_WORDS.items():
+ if re.search(rf"\b{word}\b", text, re.IGNORECASE):
+ return n
+ return None
+
+
+def _is_delete_request(text: str) -> bool:
+ return bool(
+ _DELETE_VERB_RE.search(text)
+ and (_REMINDER_WORD_RE.search(text) or _NOTE_WORD_RE.search(text))
+ and not _NOTE_PREFIX_RE.match(text)
+ and not _NOTE_CREATE_RE.match(text)
+ and not _REMINDER_PREFIX_RE.match(text)
+ )
+
+
+# --- Edit / append (also deterministic, for the same reason as delete). ---
+_EDIT_VERB_RE = re.compile(
+ r"\b(a[ñn][aá]de\w*|agr[eé]ga\w*|cambia\w*|modifica\w*|edita\w*|"
+ r"actualiza\w*|corrige|rectifica\w*|renombra\w*)\b",
+ re.IGNORECASE,
+)
+_APPEND_VERB_RE = re.compile(r"\b(a[ñn][aá]de\w*|agr[eé]ga\w*)\b", re.IGNORECASE)
+_CONNECTOR_RE = re.compile(r"^(?:que|a|al|por|con)\b\s*", re.IGNORECASE)
+_PUNCT_RE = re.compile(r"^[\s:,.\-–]+")
+
+
+def _is_edit_request(text: str) -> bool:
+ return bool(
+ _EDIT_VERB_RE.search(text)
+ and (_REMINDER_WORD_RE.search(text) or _NOTE_WORD_RE.search(text))
+ and not _NOTE_PREFIX_RE.match(text)
+ and not _NOTE_CREATE_RE.match(text)
+ and not _REMINDER_PREFIX_RE.match(text)
+ )
+
+
+# --- Move to another context ("mueve la nota 3 a Madrid"). Checked before edit
+# so "cambia el contexto de la nota 3 a Madrid" re-files instead of editing. ---
+_MOVE_VERB_RE = re.compile(
+ r"\b(mueve\w*|mover|traslada\w*|reasigna\w*|recoloca\w*|reub[íi]ca\w*)\b",
+ re.IGNORECASE,
+)
+_CONTEXT_WORD_RE = re.compile(r"\bcontexto\b", re.IGNORECASE)
+
+
+def _is_move_request(text: str) -> bool:
+ return bool(
+ (_MOVE_VERB_RE.search(text) or _CONTEXT_WORD_RE.search(text))
+ and (_REMINDER_WORD_RE.search(text) or _NOTE_WORD_RE.search(text))
+ and not _NOTE_PREFIX_RE.match(text)
+ and not _NOTE_CREATE_RE.match(text)
+ and not _REMINDER_PREFIX_RE.match(text)
+ )
+
+
+def _edit_payload(text: str) -> str:
+ """The new content/time: everything after the id number, else after the
+ target word, else after 'último'."""
+ for pat in (r"\b\d{1,6}\b",
+ r"\b(?:notas?|apuntes?|recordatorios?|avisos?|alarmas?)\b",
+ r"\b[úu]ltim[oa]s?\b"):
+ matches = list(re.finditer(pat, text, re.IGNORECASE))
+ if matches:
+ rest = text[matches[-1].end():]
+ rest = _PUNCT_RE.sub("", rest.strip())
+ rest = _CONNECTOR_RE.sub("", rest)
+ return _PUNCT_RE.sub("", rest).strip()
+ return ""
+
+
+# Leading "save this" verbs we trim so a note reads cleanly, without an LLM
+# rewrite (which would risk paraphrasing away content).
+_NOTE_PREFIX_RE = re.compile(
+ r"^\s*(?:oye\s+|vale\s+)?"
+ r"(?:ap[úu]nta(?:me)?|anota(?:me)?|gu[áa]rda(?:me)?|recuerda|nota)\b"
+ r"[:,]?\s*(?:(?:de\s+)?que\s+|lo\s+siguiente[:,]?\s*)?",
+ re.IGNORECASE,
+)
+
+# A note-CREATION wrapper — distinct from an *edit* of an existing note. Matches
+# "añade/agrega una nota que diga (que) …", "añade una nota: …", and the common
+# Whisper mishearing where the leading verb is garbled but "una nota que diga
+# que …" survives ("allá de una nota que diga que …"). Two safe shapes:
+# • <article> nota que diga|ponga … (the "a note that says …" form)
+# • una|otra nota[:,] … (an explicit *indefinite* note)
+# Neither can match an edit like "añade a la nota 3 …": that has a number right
+# after the noun and the definite "a la", so both alternatives fail. This is why
+# we check it before _is_edit_request / _is_delete_request, which otherwise
+# mistake "añade una nota …" for an append to note #N.
+_NOTE_CREATE_RE = re.compile(
+ r"^\s*(?:oye\s+|vale\s+)?"
+ r"(?:\S+\s+){0,2}" # 0-2 leading words (verb, maybe garbled)
+ r"(?:"
+ r"(?:una|la|otra|el)\s+(?:nota|apunte)s?\s+"
+ r"que\s+(?:diga|ponga|digan|pongan|pon)\w*\s+(?:que\s+)?"
+ r"|"
+ r"(?:una|otra)\s+(?:nota|apunte)s?\s*[:,]\s*"
+ r")",
+ re.IGNORECASE,
+)
+
+# A long, rambling message is a memo ("tape recorder") — keep it verbatim as a
+# note even if it mentions things to do, rather than squeezing it into a
+# paraphrased reminder and losing content.
+_LONG_NOTE_CHARS = 220
+
+
+def _clean_note(text: str) -> str:
+ cleaned = _NOTE_CREATE_RE.sub("", text, count=1)
+ if cleaned == text:
+ cleaned = _NOTE_PREFIX_RE.sub("", text, count=1)
+ cleaned = cleaned.strip()
+ return cleaned or text.strip()
+
+
+def _fmt_local(iso_utc: str, tz) -> str:
+ dt = datetime.fromisoformat(iso_utc).astimezone(tz)
+ return dt.strftime("%d/%m %H:%M")
+
+
+def _detect_context(text: str, contexts) -> str | None:
+ """Return the named context mentioned in the message, or None (= general)."""
+ low = text.lower()
+ for c in contexts:
+ if re.search(rf"\b{re.escape(c)}\b", low):
+ return c
+ return None
+
+
+def _ctx_label(context: str | None) -> str:
+ return "" if not context or context == "general" else f" 📍{context.capitalize()}"
+
+
+class Butler:
+ def __init__(self, db: Database, llm: LLM, tz, contexts=("burgos", "madrid")):
+ self.db = db
+ self.llm = llm
+ self.tz = tz
+ self.contexts = [c.lower() for c in contexts]
+
+ async def handle_message(self, text: str, source: str = "text") -> str:
+ text = (text or "").strip()
+ if not text:
+ return "No he recibido nada que procesar."
+
+ # Deterministic, high-priority intents the LLM gets wrong. Handled before
+ # classification so a delete/undo is never mistakenly stored as a note.
+ if _UNDO_RE.search(text):
+ return await self.undo_last()
+ # Move-to-context is checked before edit so "cambia el contexto de la nota
+ # 3 a Madrid" re-files instead of overwriting the note's text.
+ if _is_move_request(text):
+ return self.handle_move(text)
+ # Edit is checked before delete: "añade … borrador" should append, not
+ # delete (the word "borrador" merely contains "borra").
+ if _is_edit_request(text):
+ return await self.handle_edit(text)
+ if _is_delete_request(text):
+ return self.handle_delete(text)
+
+ intent = await self.llm.classify(text)
+
+ # Safety net: a clearly interrogative message that the model filed as a
+ # note is almost always a question about existing notes.
+ if intent.action == "note" and _looks_like_question(text):
+ intent.action = "question"
+ intent.query = intent.note_text or text
+
+ # A specific question mislabelled as a listing ("¿dónde aparqué el
+ # coche?") should search the notes, unless it actually asks to see the
+ # notes/reminders list ("¿qué notas tengo?").
+ if (intent.action in ("list_notes", "list_reminders")
+ and _looks_like_question(text)
+ and not _LIST_WORDS_RE.search(text)):
+ intent.action = "question"
+ intent.query = text
+
+ # Memo safety net: a long brain-dump is a note to keep verbatim, never a
+ # paraphrased reminder. Questions are exempt (handled above).
+ if len(text) > _LONG_NOTE_CHARS and intent.action in ("note", "reminder", "chitchat"):
+ intent.action = "note"
+
+ # Which context (Burgos/Madrid/…) is mentioned? None means "general" on
+ # write, and "no filter / all contexts" on read.
+ ctx = _detect_context(text, self.contexts)
+
+ # Date-scoped recall: "¿qué grabé ayer?", "mis notas de la semana pasada".
+ if intent.action in ("question", "list_notes"):
+ rng = parse_date_range(text, self.tz)
+ if rng:
+ return await self._recall_range(text, rng[0], rng[1], context=ctx)
+
+ if intent.action == "note":
+ # Store the ORIGINAL text verbatim (lightly de-prefixed), so nothing
+ # the user said is ever lost to an LLM rewrite.
+ return await self._save_note(_clean_note(text), source, context=ctx or "general")
+
+ if intent.action == "reminder":
+ return await self._create_reminder(intent.reminder_text or text, intent.when,
+ context=ctx or "general")
+
+ if intent.action == "question":
+ return await self._answer_question(intent.query or text, context=ctx)
+
+ if intent.action == "list_reminders":
+ return self.list_reminders(context=ctx)
+
+ if intent.action == "list_notes":
+ return self.list_notes(context=ctx)
+
+ if intent.action == "summary":
+ return self.morning_summary_data()[0]
+
+ # chitchat / fallback: hold a real (brief) conversation.
+ try:
+ return await self.llm.chat(text)
+ except Exception:
+ return intent.reply or "Aquí estoy, a tu servicio. ✅"
+
+ # ---------------- actions ----------------
+
+ async def _save_note(self, content: str, source: str,
+ context: str = "general") -> str:
+ emb = await self.llm.embed(content)
+ tags = await self.llm.suggest_tags(content)
+ note_id = self.db.add_note(content, source=source, embedding=emb,
+ tags=" ".join(tags), context=context)
+ self.db.log_op("create_note", note_id, f"nota #{note_id}")
+ preview = content if len(content) <= 200 else content[:200].rstrip() + "…"
+ tagline = f"\n🏷️ {' '.join('#' + t for t in tags)}" if tags else ""
+ return (f"📝 Nota guardada (#{note_id}){_ctx_label(context)}:\n"
+ f"{preview}{tagline}\n(«deshaz» para anular)")
+
+ async def _recall_range(self, query: str, start: datetime, end: datetime,
+ context: str | None = None) -> str:
+ notes = self.db.notes_in_range(start, end, context=context)
+ if not notes:
+ scope = _ctx_label(context).strip() or "ese periodo"
+ return f"No tengo nada guardado de {scope}. 🤔"
+ return await self.llm.answer(query, [n.content for n in notes])
+
+ async def _create_reminder(self, text: str, when: str,
+ context: str = "general") -> str:
+ due = parse_when(when, self.tz)
+ if due is None:
+ # Couldn't parse a time — keep it as a note so nothing is lost
+ # (this also logs a create_note op, so it's undoable).
+ await self._save_note(text, "reminder-fallback", context=context)
+ return (
+ "⏰ No he entendido la fecha/hora del recordatorio, así que lo he "
+ f"guardado como nota:\n{text}\n\n"
+ "Prueba de nuevo con algo como «recuérdame mañana a las 9 …»."
+ )
+ rid = self.db.add_reminder(text, due, context=context)
+ self.db.log_op("create_reminder", rid, f"recordatorio #{rid}")
+ return (
+ f"⏰ Recordatorio #{rid} programado para "
+ f"{due.astimezone(self.tz).strftime('%A %d/%m a las %H:%M')}{_ctx_label(context)}:"
+ f"\n{text}\n(«deshaz» para anular)"
+ )
+
+ # ---------------- delete / undo ----------------
+
+ def mark_reminder_done(self, reminder_id: int) -> bool:
+ ok = self.db.mark_done(reminder_id)
+ if ok:
+ self.db.log_op("done_reminder", reminder_id, f"recordatorio #{reminder_id}")
+ return ok
+
+ def handle_delete(self, text: str) -> str:
+ target = ("reminder" if _REMINDER_WORD_RE.search(text)
+ else "note" if _NOTE_WORD_RE.search(text) else None)
+ rid = _extract_id(text)
+ wants_last = bool(_LAST_RE.search(text))
+
+ if target is None:
+ return ("¿Quieres borrar una nota o un recordatorio? Dime, p.ej., "
+ "«borra el recordatorio 3» o usa /borrar.")
+ noun = "recordatorio" if target == "reminder" else "nota"
+
+ if rid is None and wants_last:
+ rid = self._last_id(target)
+ if rid is None:
+ return (f"¿Cuál {noun}? Dime el número (mira /{'recordatorios' if target=='reminder' else 'notas'}) "
+ f"o «borra el último {noun}».")
+
+ if target == "reminder":
+ ok = self.db.soft_delete_reminder(rid)
+ if ok:
+ self.db.log_op("delete_reminder", rid, f"recordatorio #{rid}")
+ else:
+ ok = self.db.soft_delete_note(rid)
+ if ok:
+ self.db.log_op("delete_note", rid, f"nota #{rid}")
+ if not ok:
+ return f"No encontré {('el recordatorio' if target=='reminder' else 'la nota')} #{rid}."
+ return f"🗑️ {noun.capitalize()} #{rid} borrad{'o' if target=='reminder' else 'a'}. («deshaz» para recuperarlo.)"
+
+ def handle_move(self, text: str) -> str:
+ target = ("reminder" if _REMINDER_WORD_RE.search(text)
+ else "note" if _NOTE_WORD_RE.search(text) else None)
+ if target is None:
+ return "¿Quieres mover una nota o un recordatorio? Usa /contexto."
+ rid = _extract_id(text)
+ if rid is None and _LAST_RE.search(text):
+ rid = self._last_id(target)
+ noun = "recordatorio" if target == "reminder" else "nota"
+ if rid is None:
+ return f"¿Cuál {noun}? Dime el número."
+ # Destination context: a configured name, or "general".
+ dest = _detect_context(text, self.contexts)
+ if dest is None and re.search(r"\bgeneral\b", text, re.IGNORECASE):
+ dest = "general"
+ if dest is None:
+ opciones = ", ".join(self.contexts + ["general"])
+ return f"¿A qué contexto? Opciones: {opciones}."
+
+ if target == "reminder":
+ cur = self.db.get_reminder(rid)
+ old = cur.context if cur else None
+ ok = self.db.set_reminder_context(rid, dest)
+ else:
+ cur = self.db.get_note(rid)
+ old = cur.context if cur else None
+ ok = self.db.set_note_context(rid, dest)
+ if not ok:
+ return f"No encontré {('el recordatorio' if target=='reminder' else 'la nota')} #{rid}."
+ kind = "move_reminder" if target == "reminder" else "move_note"
+ self.db.log_op(kind, rid, f"{noun} #{rid}", json.dumps({"context": old or "general"}))
+ return (f"📍 {noun.capitalize()} #{rid} movid{'o' if target=='reminder' else 'a'} a "
+ f"{dest.capitalize()}. («deshaz» para revertir)")
+
+ def _last_id(self, target: str) -> int | None:
+ if target == "note":
+ notes = self.db.recent_notes(limit=1)
+ return notes[0].id if notes else None
+ rems = self.db.pending_reminders()
+ return max((r.id for r in rems), default=None)
+
+ async def handle_edit(self, text: str) -> str:
+ target = ("reminder" if _REMINDER_WORD_RE.search(text)
+ else "note" if _NOTE_WORD_RE.search(text) else None)
+ if target is None:
+ return "¿Quieres editar una nota o un recordatorio? Usa /editar o dime el número."
+ rid = _extract_id(text)
+ if rid is None and _LAST_RE.search(text):
+ rid = self._last_id(target)
+ noun = "recordatorio" if target == "reminder" else "nota"
+ if rid is None:
+ return f"¿Cuál {noun}? Dime el número (mira /{'recordatorios' if target=='reminder' else 'notas'})."
+ append = bool(_APPEND_VERB_RE.search(text))
+ payload = _edit_payload(text)
+ if not payload:
+ return f"¿Qué quieres {'añadir' if append else 'poner'}? Escríbelo después del número."
+ if target == "note":
+ return await self._edit_note(rid, payload, append)
+ return await self._edit_reminder(rid, payload, append)
+
+ async def _edit_note(self, note_id: int, payload: str, append: bool) -> str:
+ note = self.db.get_note(note_id)
+ if note is None:
+ return f"No encontré la nota #{note_id}."
+ snapshot = json.dumps({"content": note.content, "tags": note.tags})
+ new_content = f"{note.content}\n{payload}" if append else payload
+ emb = await self.llm.embed(new_content)
+ tags = await self.llm.suggest_tags(new_content)
+ self.db.update_note(note_id, new_content, emb, " ".join(tags))
+ self.db.log_op("edit_note", note_id, f"nota #{note_id}", snapshot)
+ preview = new_content if len(new_content) <= 200 else new_content[:200].rstrip() + "…"
+ verb = "ampliada" if append else "actualizada"
+ return f"✏️ Nota #{note_id} {verb}:\n{preview}\n(«deshaz» para revertir)"
+
+ async def _edit_reminder(self, rid: int, payload: str, append: bool) -> str:
+ rem = self.db.get_reminder(rid)
+ if rem is None:
+ return f"No encontré el recordatorio #{rid}."
+ snapshot = json.dumps({"text": rem.text, "due_at": rem.due_at})
+ if append:
+ self.db.update_reminder(rid, text=f"{rem.text} {payload}")
+ change = "ampliado"
+ else:
+ due = parse_when(payload, self.tz)
+ if due is not None:
+ self.db.update_reminder(rid, due_at_utc=due, clear_notified=True)
+ change = ("reprogramado para "
+ + due.astimezone(self.tz).strftime("%A %d/%m a las %H:%M"))
+ else:
+ self.db.update_reminder(rid, text=payload)
+ change = "actualizado"
+ self.db.log_op("edit_reminder", rid, f"recordatorio #{rid}", snapshot)
+ return f"✏️ Recordatorio #{rid} {change}.\n(«deshaz» para revertir)"
+
+ async def undo_last(self) -> str:
+ op = self.db.last_op()
+ if op is None:
+ return "No hay nada que deshacer. 🤷"
+
+ if op.kind == "done_reminder":
+ self.db.set_done(op.target_id, False)
+ elif op.kind == "create_note":
+ self.db.soft_delete_note(op.target_id)
+ elif op.kind == "create_reminder":
+ self.db.soft_delete_reminder(op.target_id)
+ elif op.kind == "delete_note":
+ self.db.restore_note(op.target_id)
+ elif op.kind == "delete_reminder":
+ self.db.restore_reminder(op.target_id)
+ elif op.kind == "edit_note":
+ data = json.loads(op.undo_data or "{}")
+ emb = await self.llm.embed(data.get("content", ""))
+ self.db.update_note(op.target_id, data.get("content", ""), emb,
+ data.get("tags", ""))
+ elif op.kind == "edit_reminder":
+ data = json.loads(op.undo_data or "{}")
+ due = datetime.fromisoformat(data["due_at"]) if data.get("due_at") else None
+ self.db.update_reminder(op.target_id, text=data.get("text"),
+ due_at_utc=due, clear_notified=True)
+ elif op.kind == "move_note":
+ self.db.set_note_context(op.target_id, json.loads(op.undo_data or "{}").get("context", "general"))
+ elif op.kind == "move_reminder":
+ self.db.set_reminder_context(op.target_id, json.loads(op.undo_data or "{}").get("context", "general"))
+ else:
+ return "No sé cómo deshacer la última operación."
+
+ self.db.mark_op_undone(op.id)
+ verbs = {
+ "create_note": "he eliminado la nota que acababa de guardar",
+ "create_reminder": "he eliminado el recordatorio que acababa de crear",
+ "delete_note": f"he restaurado la {op.summary}",
+ "delete_reminder": f"he restaurado el {op.summary}",
+ "done_reminder": f"he reactivado el {op.summary}",
+ "edit_note": f"he revertido los cambios en la {op.summary}",
+ "edit_reminder": f"he revertido los cambios en el {op.summary}",
+ "move_note": f"he devuelto la {op.summary} a su contexto anterior",
+ "move_reminder": f"he devuelto el {op.summary} a su contexto anterior",
+ }
+ return f"↩️ Hecho: {verbs.get(op.kind, 'operación deshecha')}."
+
+ async def _answer_question(self, query: str, context: str | None = None) -> str:
+ qvec = await self.llm.embed(query)
+ notes = self.db.search_hybrid(query, qvec, limit=6, context=context)
+ answer = await self.llm.answer(query, [n.content for n in notes])
+ return answer
+
+ # ---------------- listings ----------------
+
+ def list_reminders(self, context: str | None = None) -> str:
+ rems = self.db.pending_reminders(context=context)
+ scope = _ctx_label(context).strip()
+ if not rems:
+ extra = f" de {scope}" if scope else ""
+ return f"No tienes recordatorios pendientes{extra}. ✅"
+ header = f"⏰ Recordatorios pendientes{(' · ' + scope) if scope else ''}:"
+ lines = [header]
+ for r in rems:
+ # Only show the per-item context label when not already filtering.
+ label = _ctx_label(r.context) if context is None else ""
+ lines.append(f" #{r.id} · {_fmt_local(r.due_at, self.tz)} · {r.text}{label}")
+ return "\n".join(lines)
+
+ def _note_line(self, n) -> str:
+ body = n.content if len(n.content) <= 160 else n.content[:160].rstrip() + "…"
+ tags = f" 🏷️ {' '.join('#' + t for t in n.tags.split())}" if n.tags else ""
+ label = _ctx_label(n.context)
+ return f" #{n.id} · {_fmt_local(n.created_at, self.tz)}{label} · {body}{tags}"
+
+ def list_notes(self, limit: int = 10, context: str | None = None) -> str:
+ notes = self.db.recent_notes(limit=limit, context=context)
+ scope = _ctx_label(context).strip()
+ if not notes:
+ extra = f" de {scope}" if scope else ""
+ return f"Aún no has guardado ninguna nota{extra}."
+ header = f"📝 Notas recientes{(' · ' + scope) if scope else f' (últimas {len(notes)})'}:"
+ lines = [header]
+ lines += [self._note_line(n) for n in notes]
+ return "\n".join(lines)
+
+ # ---------------- topics / browse ----------------
+
+ def list_topics(self) -> str:
+ tags = self.db.all_tags()
+ if not tags:
+ return "Aún no hay temas. Guarda algunas notas y las etiquetaré por ti."
+ lines = ["🏷️ Tus temas (usa /tema <nombre> para ver las notas):"]
+ lines += [f" #{tag} ({count})" for tag, count in tags]
+ return "\n".join(lines)
+
+ def notes_for_tag(self, tag: str) -> str:
+ tag = tag.lstrip("#").strip().lower()
+ notes = self.db.notes_by_tag(tag)
+ if not notes:
+ return f"No tengo notas con el tema «{tag}»."
+ lines = [f"🏷️ Notas de «{tag}»:"]
+ lines += [self._note_line(n) for n in notes]
+ return "\n".join(lines)
+
+ def export_markdown(self) -> str:
+ """A full dump of every note as Markdown, for /export."""
+ notes = self.db.all_notes()
+ now = datetime.now(self.tz).strftime("%d/%m/%Y %H:%M")
+ out = [f"# Notas de Segismundo", f"_Exportado el {now} — {len(notes)} notas_\n"]
+ for n in notes:
+ ts = datetime.fromisoformat(n.created_at).astimezone(self.tz)
+ tags = f" — _{', '.join('#' + t for t in n.tags.split())}_" if n.tags else ""
+ src = "🎙️" if n.source == "voice" else "⌨️"
+ out.append(f"## #{n.id} · {ts.strftime('%d/%m/%Y %H:%M')} {src}{tags}\n")
+ out.append(n.content + "\n")
+ return "\n".join(out)
+
+ # ---------------- morning summary ----------------
+
+ def context_briefing(self, context: str) -> tuple[str, dict]:
+ """An 'I just arrived' briefing for one context. Returns (text, dict)."""
+ label = context.capitalize()
+ rems = self.db.pending_reminders(context=context)
+ notes = self.db.recent_notes(limit=10, context=context)
+
+ parts = [f"📍 {label} — esto es lo que tienes aquí:\n"]
+ if rems:
+ parts.append("⏰ Recordatorios:")
+ for r in rems:
+ local = datetime.fromisoformat(r.due_at).astimezone(self.tz)
+ parts.append(f" · {local.strftime('%d/%m %H:%M')} — {r.text}")
+ else:
+ parts.append("⏰ Sin recordatorios pendientes.")
+ if notes:
+ parts.append("\n🗒️ Notas:")
+ parts += [f" · {n.content if len(n.content) <= 160 else n.content[:160].rstrip() + '…'}"
+ for n in notes]
+
+ data = {
+ "context": context,
+ "reminders": [
+ {"id": r.id,
+ "due": datetime.fromisoformat(r.due_at).astimezone(self.tz).isoformat(),
+ "text": r.text}
+ for r in rems
+ ],
+ "notes": [{"id": n.id, "content": n.content} for n in notes],
+ }
+ return "\n".join(parts), data
+
+ def morning_summary_data(self) -> tuple[str, dict]:
+ """Build the morning briefing. Returns (human_text, machine_dict)."""
+ now = datetime.now(self.tz)
+ rems = self.db.pending_reminders()
+
+ overdue, today, upcoming = [], [], []
+ for r in rems:
+ due_local = datetime.fromisoformat(r.due_at).astimezone(self.tz)
+ if due_local < now:
+ overdue.append((r, due_local))
+ elif due_local.date() == now.date():
+ today.append((r, due_local))
+ else:
+ upcoming.append((r, due_local))
+
+ recent = self.db.recent_notes(limit=5)
+
+ # Human-readable text.
+ parts = [f"☀️ Buenos días. Resumen del {now.strftime('%A %d/%m')}:\n"]
+ if overdue:
+ parts.append("🔴 Atrasados:")
+ parts += [f" · {dl.strftime('%d/%m %H:%M')} — {r.text}{_ctx_label(r.context)}"
+ for r, dl in overdue]
+ if today:
+ parts.append("📅 Para hoy:")
+ parts += [f" · {dl.strftime('%H:%M')} — {r.text}{_ctx_label(r.context)}"
+ for r, dl in today]
+ if upcoming:
+ parts.append("🔜 Próximos:")
+ parts += [f" · {dl.strftime('%d/%m %H:%M')} — {r.text}{_ctx_label(r.context)}"
+ for r, dl in upcoming[:5]]
+ if not (overdue or today or upcoming):
+ parts.append("No tienes recordatorios pendientes. 🎉")
+ if recent:
+ parts.append("\n🗒️ Últimas notas:")
+ parts += [f" · {n.content}" for n in recent]
+
+ text = "\n".join(parts)
+
+ data = {
+ "date": now.isoformat(),
+ "overdue": [{"id": r.id, "due": dl.isoformat(), "text": r.text,
+ "context": r.context} for r, dl in overdue],
+ "today": [{"id": r.id, "due": dl.isoformat(), "text": r.text,
+ "context": r.context} for r, dl in today],
+ "upcoming": [{"id": r.id, "due": dl.isoformat(), "text": r.text,
+ "context": r.context} for r, dl in upcoming],
+ "recent_notes": [{"id": n.id, "content": n.content, "context": n.context}
+ for n in recent],
+ }
+ return text, data