feat: proactive notifications — web_push channel + daily reminder check

Routes web_push through notification.py alongside NCT/email/Google Chat,
and fires daily reminder summaries via the scheduler.

- notification.py: _notify_web_push() + "web_push" case in notify();
  all four channels (web_push/email/nextcloud/google_chat) now routable
- scheduler.py: _run_reminder_check() daily at 09:00 — reads due reminders
  per persona via set_context(), formats up to 3 entries, calls notify()
- routers/settings.py: "web_push" added to valid notification_channel values
- static/settings.html: "Browser Push Notification" option in channel selector
- TODO__Agents.md: proactive notifications section marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-08 23:28:49 -04:00
parent 47d23a7b2f
commit 64020ad982
5 changed files with 73 additions and 11 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -361,6 +361,7 @@
color:var(--pg-text); font-size:0.95rem; outline:none;
transition:border-color 0.15s;">
<option value="">None (disabled)</option>
<option value="web_push">Browser Push Notification</option>
<option value="email">Email</option>
<option value="nextcloud">Nextcloud Talk</option>
<option value="google_chat">Google Chat</option>

View File

@@ -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