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) 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: async def notify(username: str, message: str, channel: str | None = None) -> None:
"""Send a notification to the user's preferred outbound channel. """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 3. "nextcloud" if notification_room is configured
4. Silent no-op 4. Silent no-op
Supported channels: "web_push", "email", "nextcloud", "google_chat"
Configure via home/{user}/channels.json — see module docstring. Configure via home/{user}/channels.json — see module docstring.
""" """
from auth_utils import get_user_channels from auth_utils import get_user_channels
@@ -137,7 +150,10 @@ async def notify(username: str, message: str, channel: str | None = None) -> Non
else: else:
return 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 email_override = channels.get("notification_email", "").strip() or None
await _notify_email(username, message, email_override=email_override) await _notify_email(username, message, email_override=email_override)

View File

@@ -284,7 +284,7 @@ async def save_notifications(
# Top-level notification preference # Top-level notification preference
notification_channel = notification_channel.strip() 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 channels["notification_channel"] = notification_channel
else: else:
channels.pop("notification_channel", None) 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) 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: def get_scheduler() -> AsyncIOScheduler | None:
"""Return the running scheduler instance (used by cron tools for live add/remove).""" """Return the running scheduler instance (used by cron tools for live add/remove)."""
return _scheduler return _scheduler
@@ -93,6 +134,10 @@ def start() -> None:
_scheduler.add_job(_run_long, "cron", day=1, hour=4, minute=0, id="distill_long") _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") 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-defined cron jobs from CRONS.json
_load_user_crons() _load_user_crons()

View File

@@ -361,6 +361,7 @@
color:var(--pg-text); font-size:0.95rem; outline:none; color:var(--pg-text); font-size:0.95rem; outline:none;
transition:border-color 0.15s;"> transition:border-color 0.15s;">
<option value="">None (disabled)</option> <option value="">None (disabled)</option>
<option value="web_push">Browser Push Notification</option>
<option value="email">Email</option> <option value="email">Email</option>
<option value="nextcloud">Nextcloud Talk</option> <option value="nextcloud">Nextcloud Talk</option>
<option value="google_chat">Google Chat</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` - [ ] **`task_list` priority filter** — add `priority` param alongside existing `status`
- [ ] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768 - [ ] **`http_fetch` max_chars** — optional param, default 8192, cap at 32768
### [Channel] Proactive notifications ### [Channel] Proactive notifications ✅ — 2026-05-08
Inara reaches out on her own initiative via NC Talk or Google Chat when a reminder Inara reaches out on her own initiative via NC Talk, Google Chat, email, or browser push.
fires, a cron job completes, or something else warrants attention. The cron/reminder - [x] `notification.py``notify(username, message, channel=None)` routes to NCT / email / Google Chat / web_push
infrastructure already exists — this closes the loop so she can interrupt the user. - [x] `web_push` added as a routable channel in `notification.py` (was tool-only before)
- [ ] Add outbound message helper for NC Talk (`send_nextcloud_message(user, text)`) - [x] `scheduler.py``_run_reminder_check()` daily at 09:00: reads due reminders per persona, fires `notify()` with a summary
- [ ] Add outbound message helper for Google Chat (`send_google_chat_message(user, text)`) - [x] `cron_runner.py` — already calls `notify()` on job completion (was already wired)
- [ ] Wire cron job completion and reminder triggers to call outbound helper - [x] `scheduler.py` — distill_mid and distill_long already call `notify()` on completion
- [ ] Store user preference: which channel to use for proactive notifications - [x] Settings UI — "Browser Push Notification" option added to Notification Channel selector
- [ ] `channels.json` already per-user — add `notify_channel: "nextcloud" | "google_chat" | null` - [x] `notification_channel` accepts `"web_push"` in `routers/settings.py`
### [UI] File attachments in chat ### [UI] File attachments in chat
Upload an image or document inline and have it flow into context. Natural workflow Upload an image or document inline and have it flow into context. Natural workflow