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