diff --git a/backend/app.py b/backend/app.py index fb6337f..a4569cb 100644 --- a/backend/app.py +++ b/backend/app.py @@ -18,8 +18,8 @@ from fastapi.staticfiles import StaticFiles from starlette.requests import Request from config import FRONTEND_DIST, VERSION -from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system, voice -from services import sentry, warmer +from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, reminders as reminders_router, routing, system, voice +from services import reminders, sentry, warmer # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # für alle Module (logging.getLogger(__name__)). @@ -39,6 +39,7 @@ async def lifespan(app: FastAPI): log.info("Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL) if sentry.ENABLED: tasks.append(asyncio.create_task(sentry.sentry_loop())) + tasks.append(asyncio.create_task(reminders.reminders_loop())) try: yield finally: @@ -73,6 +74,7 @@ app.include_router(connect.router) app.include_router(memory.router) app.include_router(agent.router) app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) +app.include_router(reminders_router.router) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(maintenance.router) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2ca3fc0..4f3c1d4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ ruamel.yaml>=0.18 psutil>=5.9 huggingface_hub>=0.27 mcp>=1.2.0 +tzdata>=2024.1 diff --git a/backend/routers/reminders.py b/backend/routers/reminders.py new file mode 100644 index 0000000..d354664 --- /dev/null +++ b/backend/routers/reminders.py @@ -0,0 +1,37 @@ +"""Erinnerungen & Routinen (A3) — dünner REST-Layer über services/reminders.py. +Konsument ist v.a. der Hermes-Agent via mcp_mc.py (reminder_create/list/delete); +LAN-only wie alle MC2-Endpoints.""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from services import reminders + +router = APIRouter(prefix="/api") + + +class ReminderIn(BaseModel): + text: str # was angesagt werden soll + when: str # ISO-8601; ohne Offset = Commander-Zeitzone (Europe/Berlin) + repeat: str = "" # '' einmalig | daily | weekdays | weekly + + +@router.get("/reminders") +def list_reminders() -> dict: + return {"items": reminders.list_all()} + + +@router.post("/reminders") +def create_reminder(body: ReminderIn) -> dict: + try: + return {"ok": True, "item": reminders.create(body.text, body.when, body.repeat)} + except ValueError as exc: + raise HTTPException(400, str(exc)) + + +@router.delete("/reminders/{reminder_id}") +def delete_reminder(reminder_id: int) -> dict: + try: + return {"ok": True, "deleted": reminders.delete(reminder_id)} + except KeyError as exc: + raise HTTPException(404, str(exc.args[0])) diff --git a/backend/services/announce.py b/backend/services/announce.py index d871122..20a5cf4 100644 --- a/backend/services/announce.py +++ b/backend/services/announce.py @@ -13,6 +13,8 @@ MAX_ITEMS Einträge, ein Lock für die FastAPI-Threadpool-Worker. import json import logging import os +import shutil +import subprocess import threading import time from pathlib import Path @@ -21,6 +23,8 @@ from config import MODELS_DIR log = logging.getLogger(__name__) +NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh") + STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json"))) MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200")) @@ -72,6 +76,19 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal" return item +def notify_telegram(subject: str, text: str) -> None: + """Best-effort auch auf Telegram (User ist evtl. nicht am PC). MC_NOTIFY_NO_ANNOUNCE=1 + verhindert, dass notify.sh die Meldung ZURÜCK in den Briefkasten spiegelt — der Absender + (Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op.""" + if not (os.name == "posix" and shutil.which("bash")): + return + try: + subprocess.run(["bash", NOTIFY_SH, "-s", subject, text], + timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"}) + except Exception: + log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True) + + def list_after(after: int | None, limit: int = 20) -> dict: """Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern.""" diff --git a/backend/services/reminders.py b/backend/services/reminders.py new file mode 100644 index 0000000..fcf943f --- /dev/null +++ b/backend/services/reminders.py @@ -0,0 +1,170 @@ +""" +Erinnerungen & Routinen (Lucy-Proaktivität, Faden A3). + +„Lucy, erinner mich morgen um 9 an …" — der Hermes-Agent legt per Tool (mcp_mc.py: +reminder_create) einen Eintrag an; der Wecker-Loop hier feuert ihn zur Zeit in den +Melde-Briefkasten (announce.py → Lucy spricht) und parallel auf Telegram. + +Bewusst OHNE Hermes-cron: der Wecker läuft deterministisch im MC2-Backend (systemd, +überlebt Gateway-Ausfälle) und bleibt in UNSEREN Schichten (Hermes-Quellcode/Config +bleibt Auto-Update-Kanal, Verdikt Faden 11c). + +Zeiten: ISO-8601. Ohne Offset gilt die Zeitzone des Commanders (MC_LOCAL_TZ, +Default Europe/Berlin) — die Box selbst läuft auf UTC, naive Zeiten dürfen daher +NIE als Box-Lokalzeit interpretiert werden. Wiederholung: daily | weekdays | weekly. +""" + +import json +import logging +import os +import threading +import time +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +from config import MODELS_DIR +from services import announce + +log = logging.getLogger(__name__) + +STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json"))) +LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin")) +INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden) +MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100")) +REPEATS = ("", "daily", "weekdays", "weekly") +LATE_NOTE_S = 600 # feuert >10 min zu spät (Box war aus) → ehrlich dazusagen + +_lock = threading.Lock() +_state: dict | None = None # {"next_id": int, "items": [...]} + + +def _load() -> dict: + global _state + if _state is None: + try: + _state = json.loads(STORE_PATH.read_text(encoding="utf-8")) + assert isinstance(_state.get("next_id"), int) and isinstance(_state.get("items"), list) + except Exception: + _state = {"next_id": 1, "items": []} + return _state + + +def _save(state: dict) -> None: + try: + tmp = STORE_PATH.with_suffix(".tmp") + tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") + tmp.replace(STORE_PATH) + except OSError: + log.warning("reminders: Store %s nicht schreibbar", STORE_PATH, exc_info=True) + + +def _parse_when(when: str) -> datetime: + """ISO-8601 → bewusste Zeit. Naive Angaben = Commander-Zeitzone (NICHT Box-UTC).""" + try: + dt = datetime.fromisoformat(when.strip()) + except ValueError: + raise ValueError("Zeit nicht lesbar — bitte ISO-8601 mit Datum UND Uhrzeit, z.B. 2026-07-04T09:00.") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=LOCAL_TZ) + return dt + + +def _fmt(ts: float) -> str: + """Menschlich lesbare Commander-Lokalzeit (fürs Tool-Echo/Listing).""" + return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%a %d.%m.%Y %H:%M") + + +def _advance(ts: float, repeat: str) -> float: + """Nächste Wiederholung NACH jetzt (holt verpasste Termine ohne Mehrfach-Feuern auf).""" + dt = datetime.fromtimestamp(ts, LOCAL_TZ) + now = time.time() + while dt.timestamp() <= now: + dt += timedelta(days=7 if repeat == "weekly" else 1) + if repeat == "weekdays": + while dt.weekday() > 4: # Sa/So überspringen + dt += timedelta(days=1) + return dt.timestamp() + + +def create(text: str, when: str, repeat: str = "") -> dict: + text = (text or "").strip() + if not text: + raise ValueError("Leerer Erinnerungs-Text.") + repeat = (repeat or "").strip().lower() + if repeat == "once": + repeat = "" + if repeat not in REPEATS: + raise ValueError("repeat muss leer, daily, weekdays oder weekly sein.") + ts = _parse_when(when).timestamp() + if repeat: + if ts <= time.time(): # Startpunkt vorbei → auf die nächste Wiederholung rücken (Uhrzeit bleibt exakt) + ts = _advance(ts, repeat) + elif ts <= time.time() - 60: + raise ValueError(f"Die Zeit liegt in der Vergangenheit ({_fmt(ts)}) — bitte neu umrechnen " + f"(jetzt ist {_fmt(time.time())}).") + with _lock: + state = _load() + if len(state["items"]) >= MAX_ITEMS: + raise ValueError(f"Zu viele offene Erinnerungen (max {MAX_ITEMS}).") + item = {"id": state["next_id"], "text": text[:500], "next_ts": ts, + "repeat": repeat, "created_ts": time.time()} + state["next_id"] += 1 + state["items"].append(item) + _save(state) + log.info("reminder #%s angelegt: %s → %s%s", item["id"], _fmt(ts), text[:60], + f" (Routine {repeat})" if repeat else "") + return {**item, "when_local": _fmt(ts)} + + +def list_all() -> list[dict]: + with _lock: + items = sorted(_load()["items"], key=lambda i: i["next_ts"]) + return [{**i, "when_local": _fmt(i["next_ts"])} for i in items] + + +def delete(reminder_id: int) -> dict: + with _lock: + state = _load() + for i, item in enumerate(state["items"]): + if item["id"] == reminder_id: + state["items"].pop(i) + _save(state) + return {**item, "when_local": _fmt(item["next_ts"])} + raise KeyError(f"Erinnerung {reminder_id} nicht gefunden.") + + +def _fire_due() -> None: + now = time.time() + with _lock: + state = _load() + due = [i for i in state["items"] if i["next_ts"] <= now] + if not due: + return + for item in due: + if item["repeat"]: + item["next_ts"] = _advance(item["next_ts"], item["repeat"]) + else: + state["items"].remove(item) + _save(state) + for item in due: + late = now - item["next_ts"] > LATE_NOTE_S if not item["repeat"] else False + text = f"Erinnerung, Commander: {item['text']}" + if late: + text += f" (Eigentlich fällig um {_fmt(item['next_ts'])} — die Box war wohl aus.)" + announce.add(text, subject="[Erinnerung]", source="reminder") + announce.notify_telegram("[Erinnerung]", item["text"]) + log.info("reminder #%s gefeuert%s", item["id"], " (Routine)" if item["repeat"] else "") + + +async def reminders_loop() -> None: + """Wecker (Hintergrund-Task im MC2-Lifespan). Verpasste einmalige Erinnerungen + (Box war aus) feuern beim nächsten Tick nach — ehrlich als verspätet markiert.""" + import asyncio + log.info("reminders: Wecker aktiv (Tick %ss, Zeitzone %s)", INTERVAL, LOCAL_TZ) + while True: + try: + await asyncio.to_thread(_fire_due) + except Exception: + log.debug("reminders: Tick fehlgeschlagen", exc_info=True) + await asyncio.sleep(INTERVAL) diff --git a/backend/services/sentry.py b/backend/services/sentry.py index 256730b..50ff2cf 100644 --- a/backend/services/sentry.py +++ b/backend/services/sentry.py @@ -16,8 +16,6 @@ Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backen import asyncio import logging import os -import shutil -import subprocess import time import httpx @@ -34,8 +32,6 @@ START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nac FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min) REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90")) -NOTIFY_SH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "deploy", "notify.sh") def _reach(url: str, path: str = "/health") -> bool: @@ -102,16 +98,9 @@ _watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS} def _notify_telegram(subject: str, text: str) -> None: - """Best-effort auch auf Telegram (User ist evtl. nicht am PC). notify.sh spiegelt - seinerseits in den Briefkasten — als Quelle 'sentry' markierte Einträge legt der - Wächter aber schon selbst ab, darum hier der Direktweg NUR für Telegram.""" - if not (os.name == "posix" and shutil.which("bash")): - return # Dev auf Windows: kein hermes/notify - try: - subprocess.run(["bash", NOTIFY_SH, "-s", subject, text + " (Diese Meldung kam auch an Lucy.)"], - timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"}) - except Exception: - log.warning("sentry: notify.sh fehlgeschlagen", exc_info=True) + """Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag + legt der Wächter selbst ab.""" + announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)") def _tick() -> None: diff --git a/mcp/mcp_mc.py b/mcp/mcp_mc.py index baf188b..2b524d7 100644 --- a/mcp/mcp_mc.py +++ b/mcp/mcp_mc.py @@ -273,6 +273,46 @@ def apply_update(kind: str) -> str: return json.dumps(_post(routes[kind]), ensure_ascii=False, indent=2) +# ── Erinnerungen & Routinen (A3): die Box sagt dem Commander zur Zeit aktiv Bescheid ───────── +@mcp.tool() +def reminder_create(text: str, when: str, repeat: str = "") -> str: + """Legt eine Erinnerung an: zur angegebenen Zeit sagt die Box dem Commander den Text AKTIV + an (Lucy spricht + Telegram). NUR nutzen, wenn der Commander ausdrücklich erinnert werden + will ('erinner mich', 'sag mir um', 'jeden Morgen'). + when = ISO-8601 mit Datum UND Uhrzeit in der Lokalzeit des Commanders, z.B. 2026-07-04T09:00. + Relative Angaben ('in 20 Minuten', 'morgen früh') selbst aus der aktuellen Zeit im Kontext + umrechnen. repeat: '' einmalig | daily (täglich) | weekdays (Mo–Fr) | weekly (wöchentlich). + text = die Ansage selbst, kurz und direkt (ohne 'Erinnerung:' davor — das ergänzt die Box).""" + try: + item = _post("/api/reminders", {"text": text, "when": when, "repeat": repeat})["item"] + except httpx.HTTPStatusError as e: + return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}" + rep = {"daily": " (täglich)", "weekdays": " (werktags)", "weekly": " (wöchentlich)"}.get(item["repeat"], "") + return f"Erinnerung #{item['id']} angelegt: {item['when_local']}{rep} — „{item['text']}“" + + +@mcp.tool() +def reminder_list() -> str: + """Zeigt alle anstehenden Erinnerungen/Routinen (Nummer, Zeit, Text). Vor dem Löschen + hiermit die Nummer ermitteln.""" + items = _get("/api/reminders")["items"] + if not items: + return "Keine Erinnerungen angelegt." + rep = {"daily": " · täglich", "weekdays": " · werktags", "weekly": " · wöchentlich"} + return "\n".join(f"#{i['id']} · {i['when_local']}{rep.get(i['repeat'], '')} — {i['text']}" for i in items) + + +@mcp.tool() +def reminder_delete(reminder_id: int) -> str: + """Löscht eine Erinnerung/Routine endgültig (Nummer aus reminder_list). NUR auf + ausdrücklichen Wunsch des Commanders.""" + try: + gone = _delete(f"/api/reminders/{reminder_id}")["deleted"] + except httpx.HTTPStatusError as e: + return f"Fehlgeschlagen: {e.response.json().get('detail', e.response.text[:200])}" + return f"Erinnerung #{gone['id']} gelöscht ({gone['when_local']} — „{gone['text']}“)." + + @mcp.tool() def service_logs(service: str, lines: int = 100) -> str: """Liest die letzten Log-Zeilen eines Dienstes (Diagnose). service z.B. hermes-gateway,