Compare commits

...

4 Commits

Author SHA1 Message Date
Scott Idem
c21f9a23ec fix: use Vapid.from_pem() instead of passing PEM string to webpush()
pywebpush 2.x routes string keys through Vapid.from_string() which only
handles raw/DER base64 — not PEM. Pre-build the Vapid object so the key
deserializes correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:56:17 -04:00
Scott Idem
19475610be feat: move Notifications to its own settings sub-page
Adds GET /settings/notifications (dedicated page with channel form + two
test buttons) and updates POST /settings/notifications to render that page.
Settings page now shows a compact link card instead of the full form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:43:52 -04:00
Scott Idem
3c7ecf4e4f feat: notification test endpoints — POST /api/push/test and /api/push/reminders/check
- POST /api/push/test: sends "Test notification from Cortex" via the
  user's configured notification channel (web_push / NCT / email / etc.)
- POST /api/push/reminders/check: runs the daily reminder check immediately
  for the current user, returns reminders_found count

Both require an active session cookie. Useful for verifying channel setup
without waiting for the 09:00 scheduler job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:34:58 -04:00
Scott Idem
64020ad982 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>
2026-05-08 23:28:49 -04:00
8 changed files with 469 additions and 74 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

@@ -67,12 +67,14 @@ def _get_private_key_pem() -> str:
def _send_one(sub: dict, payload: dict) -> bool: def _send_one(sub: dict, payload: dict) -> bool:
"""Send a push to a single subscription. Returns False if the endpoint is stale (410).""" """Send a push to a single subscription. Returns False if the endpoint is stale (410)."""
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
from py_vapid import Vapid
try: try:
vapid = Vapid.from_pem(_get_private_key_pem().encode())
webpush( webpush(
subscription_info=sub, subscription_info=sub,
data=json.dumps(payload), data=json.dumps(payload),
vapid_private_key=_get_private_key_pem(), vapid_private_key=vapid,
vapid_claims={"sub": settings.vapid_contact}, vapid_claims={"sub": settings.vapid_contact},
) )
return True return True

View File

@@ -58,3 +58,63 @@ async def unsubscribe(req: UnsubscribeRequest, request: Request) -> dict:
username = _require_user(request) username = _require_user(request)
found = push_utils.remove_subscription(username, req.endpoint) found = push_utils.remove_subscription(username, req.endpoint)
return {"ok": True, "found": found} return {"ok": True, "found": found}
@router.post("/test")
async def notify_test(request: Request) -> dict:
"""Send a test notification via the user's configured notification channel.
Useful for verifying channel setup (web push, NCT, email, etc.) without
waiting for a cron job or reminder to fire naturally.
"""
username = _require_user(request)
from notification import notify
await notify(username, "Test notification from Cortex — your notification channel is working.")
return {"ok": True, "user": username}
@router.post("/reminders/check")
async def reminder_check_now(request: Request) -> dict:
"""Run the reminder check for the current user immediately.
Same logic as the daily 09:00 scheduler job, but scoped to one user
and fired on demand. Returns how many reminders were found and whether
a notification was sent.
"""
import re
username = _require_user(request)
from persona import list_user_personas, set_context
from notification import notify
total_sent = 0
for persona_name in list_user_personas(username):
set_context(username, persona_name)
from tools.reminders import load_due_reminders
content = load_due_reminders()
if not content:
continue
entries = []
for line in content.splitlines():
m = re.match(r"^\d+\.\s+(.+)", line.strip())
if m:
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(username, msg)
total_sent += count
return {"ok": True, "user": username, "reminders_found": total_sent}

View File

@@ -54,6 +54,26 @@ def _preferred_persona(request: Request, username: str) -> str:
return names[0] return names[0]
def _notifications_page(username: str, back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "notifications.html").read_text()
channels = get_user_channels(username)
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
notify_email = _html.escape(channels.get("notification_email", "") or "")
nc_room = _html.escape((channels.get("nextcloud") or {}).get("notification_room", "") or "")
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "")
html = html.replace("{{ notify_channel }}", notify_ch)
html = html.replace("{{ notify_email_override }}", notify_email)
html = html.replace("{{ nc_notify_room }}", nc_room)
html = html.replace("{{ gc_webhook }}", gc_webhook)
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
if error:
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
return html
def _settings_page(username: str, personas: list[str], back_persona: str = "", success: str = "", error: str = "") -> str: def _settings_page(username: str, personas: list[str], back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "settings.html").read_text() html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username) html = html.replace("{{ username }}", username)
@@ -74,17 +94,6 @@ def _settings_page(username: str, personas: list[str], back_persona: str = "", s
allowlist_text = "" allowlist_text = ""
html = html.replace("{{ email_allowlist }}", allowlist_text) html = html.replace("{{ email_allowlist }}", allowlist_text)
# Notification channel settings
channels = get_user_channels(username)
notify_ch = _html.escape(channels.get("notification_channel", "") or "")
notify_email = _html.escape(channels.get("notification_email", "") or "")
nc_room = _html.escape((channels.get("nextcloud") or {}).get("notification_room", "") or "")
gc_webhook = _html.escape((channels.get("google_chat") or {}).get("outbound_webhook", "") or "")
html = html.replace("{{ notify_channel }}", notify_ch)
html = html.replace("{{ notify_email_override }}", notify_email)
html = html.replace("{{ nc_notify_room }}", nc_room)
html = html.replace("{{ gc_webhook }}", gc_webhook)
# Tool permission policy # Tool permission policy
policy = get_tool_policy(username) policy = get_tool_policy(username)
tool_allow_text = _html.escape("\n".join(policy.get("allow", []))) tool_allow_text = _html.escape("\n".join(policy.get("allow", [])))
@@ -261,6 +270,15 @@ async def rename_persona(
return RedirectResponse("/settings", status_code=302) return RedirectResponse("/settings", status_code=302)
@router.get("/settings/notifications", include_in_schema=False)
async def notifications_page(request: Request):
username = _get_session_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
back_persona = _preferred_persona(request, username)
return HTMLResponse(_notifications_page(username, back_persona))
@router.post("/settings/notifications", include_in_schema=False) @router.post("/settings/notifications", include_in_schema=False)
async def save_notifications( async def save_notifications(
request: Request, request: Request,
@@ -273,7 +291,6 @@ async def save_notifications(
if not username: if not username:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
back_persona = _preferred_persona(request, username) back_persona = _preferred_persona(request, username)
channels_path = app_settings.home_root() / username / "channels.json" channels_path = app_settings.home_root() / username / "channels.json"
@@ -284,7 +301,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)
@@ -308,8 +325,7 @@ async def save_notifications(
channels_path.write_text(json.dumps(channels, indent=2) + "\n") channels_path.write_text(json.dumps(channels, indent=2) + "\n")
logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none") logger.info("notifications updated for %s (channel=%s)", username, notification_channel or "none")
return HTMLResponse(_settings_page(username, personas, back_persona, return HTMLResponse(_notifications_page(username, back_persona, success="Notification settings saved."))
success="Notification settings saved."))
@router.post("/settings/tool-policy", include_in_schema=False) @router.post("/settings/tool-policy", include_in_schema=False)

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

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Notifications</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
:root {
--pg-bg: #0f1117; --pg-surface: #1a1d27;
--pg-border: #2d3148;
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
--pg-dim: #64748b; --pg-dimmer: #475569;
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
}
[data-theme="light"] {
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
--pg-border: #d0c8e8;
--pg-text: #1a1228; --pg-muted: #5a5478;
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--pg-bg);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-weight: 450;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--pg-text);
padding: 1.5rem;
}
.card {
background: var(--pg-surface);
border: 1px solid var(--pg-border);
border-radius: 12px;
padding: 2.5rem 2rem;
width: 100%;
max-width: 480px;
}
.page-nav {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 1.75rem;
flex-wrap: wrap;
}
.nav-link {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.6rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
color: var(--pg-dim);
text-decoration: none;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
.nav-link.active { color: #a78bfa; }
.nav-spacer { flex: 1; min-width: 0.5rem; }
.nav-link.nav-logout { color: var(--pg-dimmer); }
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
.logo { margin-bottom: 1.75rem; }
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
.logo p { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.2rem; }
h2 {
font-size: 0.9rem;
font-weight: 600;
color: var(--pg-muted);
margin-bottom: 1rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--pg-border);
}
.section { margin-bottom: 2rem; }
label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--pg-muted);
margin-bottom: 0.4rem;
}
input, select {
width: 100%;
padding: 0.65rem 0.85rem;
background: var(--pg-bg);
border: 1px solid var(--pg-border);
border-radius: 6px;
color: var(--pg-text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus { border-color: #7c3aed; }
.field { margin-bottom: 1rem; }
button[type="submit"] {
width: 100%;
padding: 0.7rem;
margin-top: 0.25rem;
background: #7c3aed;
border: none;
border-radius: 6px;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
button[type="submit"]:hover { background: #6d28d9; }
.error { color: #f87171; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
.success { color: #4ade80; font-size: 0.85rem; text-align: center; margin-bottom: 1rem; }
.btn-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.btn {
flex: 1;
padding: 0.6rem 0.75rem;
border: 1px solid var(--pg-border);
border-radius: 6px;
background: var(--pg-bg);
color: var(--pg-text);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
text-align: center;
}
.btn:hover { border-color: #7c3aed; color: #a78bfa; }
.btn:disabled { opacity: 0.5; cursor: default; }
.test-result {
margin-top: 0.75rem;
padding: 0.6rem 0.8rem;
border-radius: 6px;
font-size: 0.82rem;
line-height: 1.5;
display: none;
}
.test-result.ok { background: rgba(74, 222, 128, 0.1); color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.25); }
.test-result.err { background: rgba(248, 113, 113, 0.1); color: #f87171; border: 1px solid rgba(248, 113, 113, 0.25); }
.hint { font-size: 0.78rem; color: var(--pg-dim); margin-top: 0.35rem; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<nav class="page-nav">
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/notifications" class="nav-link active">Notifications</a>
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="logo">
<h1>Notifications</h1>
<p>How Inara reaches out proactively — reminders, cron jobs, and memory digests.</p>
</div>
<!-- SUCCESS -->
<!-- ERROR -->
<!-- Channel config -->
<div class="section">
<h2>Channel</h2>
<form method="POST" action="/settings/notifications">
<div class="field">
<label for="notification_channel">Notification channel</label>
<select id="notification_channel" name="notification_channel"
data-value="{{ notify_channel }}">
<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>
</select>
</div>
<div class="field">
<label for="notification_email">Email override
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ notify_email_override }}"
placeholder="Leave blank to use login email"
autocomplete="off">
</div>
<div class="field">
<label for="nc_notification_room">Nextcloud Talk room token</label>
<input type="text" id="nc_notification_room" name="nc_notification_room"
value="{{ nc_notify_room }}"
placeholder="Token from the Talk room URL"
autocomplete="off" spellcheck="false">
</div>
<div class="field">
<label for="gc_outbound_webhook">Google Chat webhook URL</label>
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
value="{{ gc_webhook }}"
placeholder="https://chat.googleapis.com/v1/spaces/…"
autocomplete="off" spellcheck="false">
</div>
<button type="submit">Save notification settings</button>
</form>
</div>
<!-- Test -->
<div class="section">
<h2>Test</h2>
<p class="hint" style="margin-bottom:0.85rem">
Fire a notification via your configured channel or run the reminder check
immediately — no need to wait for the daily 09:00 scheduler job.
</p>
<div class="btn-row">
<button class="btn" id="btn-test-notify">Send Test Notification</button>
<button class="btn" id="btn-check-reminders">Check Reminders Now</button>
</div>
<div class="test-result" id="test-result"></div>
</div>
</div>
<script>
// Set channel select to saved value
const sel = document.getElementById('notification_channel');
if (sel) {
const saved = sel.dataset.value;
if (saved) {
for (const opt of sel.options) {
if (opt.value === saved) { opt.selected = true; break; }
}
}
}
// Test buttons
const resultEl = document.getElementById('test-result');
function showResult(ok, msg) {
resultEl.textContent = msg;
resultEl.className = 'test-result ' + (ok ? 'ok' : 'err');
resultEl.style.display = 'block';
}
async function apiPost(url, btnEl, label) {
btnEl.disabled = true;
btnEl.textContent = label + '…';
resultEl.style.display = 'none';
try {
const r = await fetch(url, { method: 'POST' });
const data = await r.json();
if (r.ok && data.ok) {
if (url.includes('reminders')) {
const n = data.reminders_found ?? 0;
showResult(true, n > 0
? `Found ${n} due reminder${n !== 1 ? 's' : ''} — notification sent.`
: 'No due reminders found — nothing sent.');
} else {
showResult(true, 'Notification sent. Check your configured channel.');
}
} else {
showResult(false, data.detail || 'Request failed.');
}
} catch (e) {
showResult(false, 'Network error: ' + e.message);
} finally {
btnEl.disabled = false;
btnEl.textContent = label;
}
}
document.getElementById('btn-test-notify').addEventListener('click', function() {
apiPost('/api/push/test', this, 'Send Test Notification');
});
document.getElementById('btn-check-reminders').addEventListener('click', function() {
apiPost('/api/push/reminders/check', this, 'Check Reminders Now');
});
</script>
</body>
</html>

View File

@@ -264,6 +264,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a> <a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a> <a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link active">Settings</a> <a href="/settings" class="nav-link active">Settings</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>
@@ -348,49 +349,14 @@
<div class="section"> <div class="section">
<h2>Notifications</h2> <h2>Notifications</h2>
<p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;"> <p style="font-size:0.8rem; color:var(--pg-muted); margin-bottom:0.85rem; line-height:1.55;">
Choose how Inara reaches out proactively — cron jobs, briefs, and future alerts. Configure how Inara reaches out proactively — reminders, cron jobs, and memory digests.
Email defaults to your login address when no override is set.
</p> </p>
<form method="POST" action="/settings/notifications"> <a href="/settings/notifications"
<div class="field"> style="display:inline-block; padding:0.55rem 1rem; background:#7c3aed; border-radius:6px;
<label for="notification_channel">Notification channel</label> color:#fff; font-size:0.88rem; font-weight:600; text-decoration:none;
<select id="notification_channel" name="notification_channel" transition:background 0.15s;">
data-value="{{ notify_channel }}" Notification settings →
style="width:100%; padding:0.65rem 0.85rem; background:var(--pg-bg); </a>
border:1px solid var(--pg-border); border-radius:6px;
color:var(--pg-text); font-size:0.95rem; outline:none;
transition:border-color 0.15s;">
<option value="">None (disabled)</option>
<option value="email">Email</option>
<option value="nextcloud">Nextcloud Talk</option>
<option value="google_chat">Google Chat</option>
</select>
</div>
<div class="field">
<label for="notification_email">Email override
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ notify_email_override }}"
placeholder="Leave blank to use login email"
autocomplete="off">
</div>
<div class="field">
<label for="nc_notification_room">Nextcloud Talk room token</label>
<input type="text" id="nc_notification_room" name="nc_notification_room"
value="{{ nc_notify_room }}"
placeholder="Token from the Talk room URL"
autocomplete="off" spellcheck="false">
</div>
<div class="field">
<label for="gc_outbound_webhook">Google Chat webhook URL</label>
<input type="url" id="gc_outbound_webhook" name="gc_outbound_webhook"
value="{{ gc_webhook }}"
placeholder="https://chat.googleapis.com/v1/spaces/…"
autocomplete="off" spellcheck="false">
</div>
<button type="submit">Save notification settings</button>
</form>
</div> </div>
<!-- Tool Permissions --> <!-- Tool Permissions -->
@@ -536,12 +502,6 @@
</div> </div>
<script> <script>
// Restore notification channel dropdown from injected value
(function() {
const sel = document.getElementById('notification_channel');
if (sel) sel.value = sel.dataset.value || '';
})();
// Password confirmation check // Password confirmation check
document.getElementById('password-form').addEventListener('submit', e => { document.getElementById('password-form').addEventListener('submit', e => {
const np = document.getElementById('new_password').value; const np = document.getElementById('new_password').value;

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