diff --git a/cortex/notification.py b/cortex/notification.py index 32db22d..0e8f7a6 100644 --- a/cortex/notification.py +++ b/cortex/notification.py @@ -114,6 +114,18 @@ async def _notify_google_chat(webhook_url: str, message: str, username: str) -> logger.error("notify Google Chat error for %s: %s", username, e) +async def _notify_web_push(username: str, message: str) -> None: + """Send a browser push notification.""" + import push_utils + result = await push_utils.send_push(username, "Cortex", message, "") + if "error" in result: + logger.warning("notify web_push error for %s: %s", username, result["error"]) + elif result.get("sent", 0) == 0: + logger.debug("notify web_push: no subscriptions for %s", username) + else: + logger.info("notify web_push → %s (%d device(s))", username, result["sent"]) + + async def notify(username: str, message: str, channel: str | None = None) -> None: """Send a notification to the user's preferred outbound channel. @@ -123,6 +135,7 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non 3. "nextcloud" if notification_room is configured 4. Silent no-op + Supported channels: "web_push", "email", "nextcloud", "google_chat" Configure via home/{user}/channels.json — see module docstring. """ from auth_utils import get_user_channels @@ -137,7 +150,10 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non else: return - if target == "email": + if target == "web_push": + await _notify_web_push(username, message) + + elif target == "email": email_override = channels.get("notification_email", "").strip() or None await _notify_email(username, message, email_override=email_override) diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py index d6f43ab..29e9556 100644 --- a/cortex/routers/settings.py +++ b/cortex/routers/settings.py @@ -284,7 +284,7 @@ async def save_notifications( # Top-level notification preference notification_channel = notification_channel.strip() - if notification_channel in ("email", "nextcloud", "google_chat"): + if notification_channel in ("web_push", "email", "nextcloud", "google_chat"): channels["notification_channel"] = notification_channel else: channels.pop("notification_channel", None) diff --git a/cortex/scheduler.py b/cortex/scheduler.py index 6af9177..08c1602 100644 --- a/cortex/scheduler.py +++ b/cortex/scheduler.py @@ -69,6 +69,47 @@ async def _run_long() -> None: logger.error("auto distill long [%s/%s] failed: %s", u, p, e) +async def _run_reminder_check() -> None: + """Notify users of any due or overdue reminders (fires once daily at 09:00).""" + import re + from notification import notify + from persona import set_context + + for u, p in _all_personas(): + try: + set_context(u, p) + from tools.reminders import load_due_reminders + content = load_due_reminders() + if not content: + continue + + # Extract numbered entries (lines like "1. [label] text" or "1. text") + entries = [] + for line in content.splitlines(): + m = re.match(r"^\d+\.\s+(.+)", line.strip()) + if m: + # Strip status tags ([OVERDUE], [due TODAY], etc.) for display + text = re.sub(r"\[(OVERDUE|due TODAY|due: \S+)\]", "", m.group(1)).strip() + if text: + entries.append(text) + + if not entries: + continue + + count = len(entries) + if count == 1: + msg = f"Reminder: {entries[0]}" + else: + bullet_list = "\n".join(f"• {e}" for e in entries[:3]) + tail = f"\n…and {count - 3} more" if count > 3 else "" + msg = f"{count} reminders due:\n{bullet_list}{tail}" + + await notify(u, msg) + logger.info("reminder check [%s/%s]: notified %d reminder(s)", u, p, count) + except Exception as e: + logger.error("reminder check [%s/%s] failed: %s", u, p, e) + + def get_scheduler() -> AsyncIOScheduler | None: """Return the running scheduler instance (used by cron tools for live add/remove).""" return _scheduler @@ -93,6 +134,10 @@ def start() -> None: _scheduler.add_job(_run_long, "cron", day=1, hour=4, minute=0, id="distill_long") logger.info("scheduled: distill_long monthly on 1st at 04:00") + # Daily reminder notification check — 09:00 + _scheduler.add_job(_run_reminder_check, "cron", hour=9, minute=0, id="reminder_check") + logger.info("scheduled: reminder_check daily at 09:00") + # Load user-defined cron jobs from CRONS.json _load_user_crons() diff --git a/cortex/static/settings.html b/cortex/static/settings.html index 5c8c776..80710dd 100644 --- a/cortex/static/settings.html +++ b/cortex/static/settings.html @@ -361,6 +361,7 @@ color:var(--pg-text); font-size:0.95rem; outline:none; transition:border-color 0.15s;"> + diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index 587a4a5..5d6b62f 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -99,15 +99,15 @@ system prompt by `context_loader.py` at all tiers. - [ ] **`task_list` priority filter** — add `priority` param alongside existing `status` - [ ] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768 -### [Channel] Proactive notifications -Inara reaches out on her own initiative via NC Talk or Google Chat when a reminder -fires, a cron job completes, or something else warrants attention. The cron/reminder -infrastructure already exists — this closes the loop so she can interrupt the user. -- [ ] Add outbound message helper for NC Talk (`send_nextcloud_message(user, text)`) -- [ ] Add outbound message helper for Google Chat (`send_google_chat_message(user, text)`) -- [ ] Wire cron job completion and reminder triggers to call outbound helper -- [ ] Store user preference: which channel to use for proactive notifications -- [ ] `channels.json` already per-user — add `notify_channel: "nextcloud" | "google_chat" | null` +### [Channel] Proactive notifications ✅ — 2026-05-08 +Inara reaches out on her own initiative via NC Talk, Google Chat, email, or browser push. +- [x] `notification.py` — `notify(username, message, channel=None)` routes to NCT / email / Google Chat / web_push +- [x] `web_push` added as a routable channel in `notification.py` (was tool-only before) +- [x] `scheduler.py` — `_run_reminder_check()` daily at 09:00: reads due reminders per persona, fires `notify()` with a summary +- [x] `cron_runner.py` — already calls `notify()` on job completion (was already wired) +- [x] `scheduler.py` — distill_mid and distill_long already call `notify()` on completion +- [x] Settings UI — "Browser Push Notification" option added to Notification Channel selector +- [x] `notification_channel` accepts `"web_push"` in `routers/settings.py` ### [UI] File attachments in chat Upload an image or document inline and have it flow into context. Natural workflow