feat: web push notifications (VAPID)
- push_utils.py: subscription storage + send helper (auto-prunes 410 endpoints) - routers/push.py: GET /api/push/vapid-key (public), POST/DELETE /api/push/subscribe - sw.js: push event listener shows notification; notificationclick focuses/opens tab - app.js: subscribe/unsubscribe flow + "Enable notifications" toggle in settings dropdown - tools/notify.py: web_push orchestrator tool (user-level, no admin required) - VAPID keys in .env; pywebpush added to requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -226,12 +226,12 @@ Cortex is running and stable. All channels are live:
|
|||||||
|
|
||||||
Active users: scott (inara, developer), holly (tina), brian (wintermute)
|
Active users: scott (inara, developer), holly (tina), brian (wintermute)
|
||||||
|
|
||||||
**39 orchestrator tools:** web_search, http_fetch,
|
**40 orchestrator tools:** web_search, http_fetch,
|
||||||
file_read/list/write, shell_exec, claude_allow_dir,
|
file_read/list/write, shell_exec, claude_allow_dir,
|
||||||
cortex_restart/logs/status/update,
|
cortex_restart/logs/status/update,
|
||||||
task_list/create/update/complete, cron_list/add/remove/toggle,
|
task_list/create/update/complete, cron_list/add/remove/toggle,
|
||||||
reminders_add/list/remove/clear, scratch_read/write/append/clear,
|
reminders_add/list/remove/clear, scratch_read/write/append/clear,
|
||||||
email_send, nc_talk_send,
|
web_push, email_send, nc_talk_send,
|
||||||
ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend,
|
ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend,
|
||||||
ae_task_list.
|
ae_task_list.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from starlette.responses import RedirectResponse, JSONResponse
|
|||||||
from auth_utils import COOKIE_NAME, decode_token
|
from auth_utils import COOKIE_NAME, decode_token
|
||||||
|
|
||||||
# Paths that don't require a session cookie
|
# Paths that don't require a session cookie
|
||||||
_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico"}
|
_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico",
|
||||||
|
"/api/push/vapid-key"}
|
||||||
|
|
||||||
# Path prefixes that are always public (setup flow + webhooks + Google OAuth)
|
# Path prefixes that are always public (setup flow + webhooks + Google OAuth)
|
||||||
_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google")
|
_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google")
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ class Settings(BaseSettings):
|
|||||||
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
|
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
|
||||||
jwt_expire_days: int = 30
|
jwt_expire_days: int = 30
|
||||||
|
|
||||||
|
# Web Push (VAPID) — for browser push notifications
|
||||||
|
# Generate once with py_vapid; see push_utils.py for key format details
|
||||||
|
vapid_public_key: str = "" # base64url-encoded uncompressed EC point (for browser)
|
||||||
|
vapid_private_key_b64: str = "" # base64-encoded PEM private key (single-line .env storage)
|
||||||
|
vapid_contact: str = "mailto:admin@example.com"
|
||||||
|
|
||||||
# SMTP — for sending invite emails
|
# SMTP — for sending invite emails
|
||||||
smtp_server: str = ""
|
smtp_server: str = ""
|
||||||
smtp_port: int = 465
|
smtp_port: int = 465
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
|
|||||||
from config import settings
|
from config import settings
|
||||||
from auth_middleware import SessionAuthMiddleware
|
from auth_middleware import SessionAuthMiddleware
|
||||||
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
||||||
from routers import ui, onboarding, settings, help, auth_google, local_llm
|
from routers import ui, onboarding, settings, help, auth_google, local_llm, push
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -34,6 +34,7 @@ app.include_router(files.router)
|
|||||||
app.include_router(distill.router)
|
app.include_router(distill.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(orchestrator.router)
|
app.include_router(orchestrator.router)
|
||||||
|
app.include_router(push.router)
|
||||||
|
|
||||||
# Static files — must be mounted BEFORE ui.router so /static/* is matched first.
|
# Static files — must be mounted BEFORE ui.router so /static/* is matched first.
|
||||||
# ui.router has a wildcard /{username}/{persona} that would otherwise catch /static/style.css etc.
|
# ui.router has a wildcard /{username}/{persona} that would otherwise catch /static/style.css etc.
|
||||||
|
|||||||
115
cortex/push_utils.py
Normal file
115
cortex/push_utils.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Web Push (VAPID) helpers.
|
||||||
|
|
||||||
|
Subscriptions are stored per-user at:
|
||||||
|
home/{user}/push_subscriptions.json → list of {endpoint, keys:{p256dh, auth}}
|
||||||
|
|
||||||
|
send_push(username, title, body, url) iterates all stored subscriptions for that
|
||||||
|
user and fires a push. Stale endpoints (410 Gone) are pruned automatically.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _subs_path(username: str) -> Path:
|
||||||
|
return settings.home_root() / username / "push_subscriptions.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_subscriptions(username: str) -> list[dict]:
|
||||||
|
path = _subs_path(username)
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_subscriptions(username: str, subs: list[dict]) -> None:
|
||||||
|
path = _subs_path(username)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(subs, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def add_subscription(username: str, sub: dict) -> None:
|
||||||
|
"""Upsert a subscription by endpoint URL."""
|
||||||
|
subs = load_subscriptions(username)
|
||||||
|
endpoint = sub.get("endpoint", "")
|
||||||
|
subs = [s for s in subs if s.get("endpoint") != endpoint]
|
||||||
|
subs.append(sub)
|
||||||
|
_save_subscriptions(username, subs)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_subscription(username: str, endpoint: str) -> bool:
|
||||||
|
subs = load_subscriptions(username)
|
||||||
|
new_subs = [s for s in subs if s.get("endpoint") != endpoint]
|
||||||
|
if len(new_subs) == len(subs):
|
||||||
|
return False
|
||||||
|
_save_subscriptions(username, new_subs)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_private_key_pem() -> str:
|
||||||
|
"""Decode the base64-encoded PEM private key from settings."""
|
||||||
|
raw = settings.vapid_private_key_b64.strip()
|
||||||
|
if not raw:
|
||||||
|
raise RuntimeError("VAPID_PRIVATE_KEY_B64 is not set in .env")
|
||||||
|
return base64.b64decode(raw).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_one(sub: dict, payload: dict) -> bool:
|
||||||
|
"""Send a push to a single subscription. Returns False if the endpoint is stale (410)."""
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=sub,
|
||||||
|
data=json.dumps(payload),
|
||||||
|
vapid_private_key=_get_private_key_pem(),
|
||||||
|
vapid_claims={"sub": settings.vapid_contact},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except WebPushException as e:
|
||||||
|
if e.response is not None and e.response.status_code == 410:
|
||||||
|
logger.info("push endpoint gone (410), pruning: %s", sub.get("endpoint", "")[:60])
|
||||||
|
return False
|
||||||
|
logger.warning("push failed: %s", e)
|
||||||
|
return True # keep the sub; might be transient
|
||||||
|
|
||||||
|
|
||||||
|
async def send_push(username: str, title: str, body: str, url: str = "") -> dict:
|
||||||
|
"""
|
||||||
|
Send a push notification to all subscriptions for username.
|
||||||
|
Returns {"sent": n, "pruned": m}.
|
||||||
|
"""
|
||||||
|
if not settings.vapid_public_key or not settings.vapid_private_key_b64:
|
||||||
|
return {"error": "VAPID keys not configured"}
|
||||||
|
|
||||||
|
subs = load_subscriptions(username)
|
||||||
|
if not subs:
|
||||||
|
return {"error": f"No push subscriptions for {username}"}
|
||||||
|
|
||||||
|
payload = {"title": title, "body": body, "url": url}
|
||||||
|
keep = []
|
||||||
|
sent = 0
|
||||||
|
pruned = 0
|
||||||
|
|
||||||
|
for sub in subs:
|
||||||
|
alive = await asyncio.to_thread(_send_one, sub, payload)
|
||||||
|
if alive:
|
||||||
|
keep.append(sub)
|
||||||
|
sent += 1
|
||||||
|
else:
|
||||||
|
pruned += 1
|
||||||
|
|
||||||
|
if pruned:
|
||||||
|
_save_subscriptions(username, keep)
|
||||||
|
|
||||||
|
return {"sent": sent, "pruned": pruned}
|
||||||
@@ -22,5 +22,8 @@ httpx>=0.27.0
|
|||||||
# OpenAI-compatible client — tool calling for OpenRouter / LiteLLM / any OAI-compat host
|
# OpenAI-compatible client — tool calling for OpenRouter / LiteLLM / any OAI-compat host
|
||||||
openai>=1.0.0
|
openai>=1.0.0
|
||||||
|
|
||||||
|
# Web Push / VAPID — browser push notifications
|
||||||
|
pywebpush>=2.0.0
|
||||||
|
|
||||||
# anthropic SDK not needed — using claude CLI subprocess for auth
|
# anthropic SDK not needed — using claude CLI subprocess for auth
|
||||||
# anthropic>=0.40.0
|
# anthropic>=0.40.0
|
||||||
|
|||||||
60
cortex/routers/push.py
Normal file
60
cortex/routers/push.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Web Push endpoints.
|
||||||
|
|
||||||
|
GET /api/push/vapid-key → public VAPID key for browser PushManager.subscribe()
|
||||||
|
POST /api/push/subscribe → save a push subscription for the logged-in user
|
||||||
|
DELETE /api/push/subscribe → remove a subscription by endpoint
|
||||||
|
"""
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth_utils import COOKIE_NAME, decode_token
|
||||||
|
from config import settings
|
||||||
|
import push_utils
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/push")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(request: Request) -> str:
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
try:
|
||||||
|
return decode_token(token)
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid session")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vapid-key")
|
||||||
|
async def get_vapid_key() -> dict:
|
||||||
|
"""Return the VAPID public key. Public endpoint — needed before login to subscribe."""
|
||||||
|
key = settings.vapid_public_key
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||||
|
return {"public_key": key}
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeRequest(BaseModel):
|
||||||
|
subscription: dict # full PushSubscription JSON from browser
|
||||||
|
|
||||||
|
|
||||||
|
class UnsubscribeRequest(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe")
|
||||||
|
async def subscribe(req: SubscribeRequest, request: Request) -> dict:
|
||||||
|
username = _require_user(request)
|
||||||
|
sub = req.subscription
|
||||||
|
if not sub.get("endpoint"):
|
||||||
|
raise HTTPException(status_code=400, detail="subscription.endpoint is required")
|
||||||
|
push_utils.add_subscription(username, sub)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/subscribe")
|
||||||
|
async def unsubscribe(req: UnsubscribeRequest, request: Request) -> dict:
|
||||||
|
username = _require_user(request)
|
||||||
|
found = push_utils.remove_subscription(username, req.endpoint)
|
||||||
|
return {"ok": True, "found": found}
|
||||||
@@ -1806,7 +1806,93 @@
|
|||||||
if (stored) resumeSession(stored, true).catch(clear_stored_session);
|
if (stored) resumeSession(stored, true).catch(clear_stored_session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service worker registration ───────────────────────────────
|
// ── Service worker + Web Push ────────────────────────────────
|
||||||
|
const pushBtn = document.getElementById('push-btn');
|
||||||
|
const pushBtnLabel = document.getElementById('push-btn-label');
|
||||||
|
|
||||||
|
function _urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(base64);
|
||||||
|
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getPushSubscription() {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
return reg.pushManager.getSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _syncPushBtn() {
|
||||||
|
if (!('PushManager' in window) || !('serviceWorker' in navigator)) return;
|
||||||
|
pushBtn.style.display = '';
|
||||||
|
const sub = await _getPushSubscription();
|
||||||
|
if (sub) {
|
||||||
|
pushBtnLabel.textContent = 'Notifications on';
|
||||||
|
pushBtn.classList.add('push-active');
|
||||||
|
} else {
|
||||||
|
pushBtnLabel.textContent = 'Enable notifications';
|
||||||
|
pushBtn.classList.remove('push-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _subscribePush() {
|
||||||
|
try {
|
||||||
|
const keyRes = await fetch('/api/push/vapid-key');
|
||||||
|
if (!keyRes.ok) { showToast('Push not configured on server'); return; }
|
||||||
|
const { public_key } = await keyRes.json();
|
||||||
|
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
if (perm !== 'granted') { showToast('Notification permission denied'); return; }
|
||||||
|
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: _urlBase64ToUint8Array(public_key),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch('/api/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subscription: sub.toJSON() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast('Push notifications enabled');
|
||||||
|
await _syncPushBtn();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Could not enable push: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _unsubscribePush() {
|
||||||
|
try {
|
||||||
|
const sub = await _getPushSubscription();
|
||||||
|
if (!sub) { await _syncPushBtn(); return; }
|
||||||
|
|
||||||
|
await fetch('/api/push/subscribe', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sub.unsubscribe();
|
||||||
|
showToast('Notifications disabled');
|
||||||
|
await _syncPushBtn();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pushBtn) {
|
||||||
|
pushBtn.addEventListener('click', async () => {
|
||||||
|
settings_dd_el.classList.remove('open');
|
||||||
|
const sub = await _getPushSubscription();
|
||||||
|
if (sub) await _unsubscribePush();
|
||||||
|
else await _subscribePush();
|
||||||
|
});
|
||||||
|
_syncPushBtn();
|
||||||
|
}
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@
|
|||||||
<a href="/settings" class="hdr-dd-item">
|
<a href="/settings" class="hdr-dd-item">
|
||||||
<svg data-lucide="user" class="btn-icon"></svg> Account
|
<svg data-lucide="user" class="btn-icon"></svg> Account
|
||||||
</a>
|
</a>
|
||||||
|
<button id="push-btn" class="hdr-dd-item" style="display:none">
|
||||||
|
<svg data-lucide="bell" class="btn-icon"></svg>
|
||||||
|
<span id="push-btn-label">Enable notifications</span>
|
||||||
|
</button>
|
||||||
<div class="hdr-dd-divider"></div>
|
<div class="hdr-dd-divider"></div>
|
||||||
<form method="POST" action="/logout" style="margin:0">
|
<form method="POST" action="/logout" style="margin:0">
|
||||||
<button type="submit" class="hdr-dd-item">
|
<button type="submit" class="hdr-dd-item">
|
||||||
|
|||||||
@@ -266,6 +266,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.hdr-dd-item:hover { background: var(--border); }
|
.hdr-dd-item:hover { background: var(--border); }
|
||||||
|
.hdr-dd-item.push-active { color: var(--accent); }
|
||||||
|
|
||||||
.hdr-dd-divider {
|
.hdr-dd-divider {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'cortex-v1';
|
const CACHE = 'cortex-v2';
|
||||||
|
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/static/style.css',
|
'/static/style.css',
|
||||||
@@ -28,6 +28,37 @@ self.addEventListener('activate', evt => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', evt => {
|
||||||
|
let data = { title: 'Cortex', body: '', url: '/' };
|
||||||
|
if (evt.data) {
|
||||||
|
try { data = { ...data, ...evt.data.json() }; } catch (_) {}
|
||||||
|
}
|
||||||
|
evt.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/static/icon-192.png',
|
||||||
|
badge: '/static/icon-192.png',
|
||||||
|
data: { url: data.url },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', evt => {
|
||||||
|
evt.notification.close();
|
||||||
|
const url = evt.notification.data?.url || '/';
|
||||||
|
evt.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
||||||
|
for (const c of list) {
|
||||||
|
if (c.url.includes(self.location.origin) && 'focus' in c) {
|
||||||
|
c.navigate(url);
|
||||||
|
return c.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clients.openWindow) return clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', evt => {
|
self.addEventListener('fetch', evt => {
|
||||||
const url = new URL(evt.request.url);
|
const url = new URL(evt.request.url);
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ from tools.scratch import (
|
|||||||
scratch_append as _scratch_append,
|
scratch_append as _scratch_append,
|
||||||
scratch_clear as _scratch_clear,
|
scratch_clear as _scratch_clear,
|
||||||
)
|
)
|
||||||
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send
|
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push
|
||||||
|
|
||||||
# ── Declaration imports ───────────────────────────────────────────────────────
|
# ── Declaration imports ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
|
|||||||
"Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
"Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
||||||
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
||||||
"Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"],
|
"Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"],
|
||||||
"Notifications": ["email_send", "nc_talk_send"],
|
"Notifications": ["web_push", "email_send", "nc_talk_send"],
|
||||||
"Aether Journals": [
|
"Aether Journals": [
|
||||||
"ae_journal_list", "ae_journal_search",
|
"ae_journal_list", "ae_journal_search",
|
||||||
"ae_journal_entries_list", "ae_journal_entry_read",
|
"ae_journal_entries_list", "ae_journal_entry_read",
|
||||||
@@ -142,6 +142,7 @@ _CALLABLES: dict[str, callable] = {
|
|||||||
"scratch_clear": _scratch_clear,
|
"scratch_clear": _scratch_clear,
|
||||||
"email_send": _email_send,
|
"email_send": _email_send,
|
||||||
"nc_talk_send": _nc_talk_send,
|
"nc_talk_send": _nc_talk_send,
|
||||||
|
"web_push": _web_push,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Role-based access control ─────────────────────────────────────────────────
|
# ── Role-based access control ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ async def email_send(to: str, subject: str, body: str) -> str:
|
|||||||
return "Failed to send email — check SMTP configuration in .env."
|
return "Failed to send email — check SMTP configuration in .env."
|
||||||
|
|
||||||
|
|
||||||
|
async def web_push(title: str, body: str, url: str = "") -> str:
|
||||||
|
"""Send a browser push notification to the current user's registered devices."""
|
||||||
|
import push_utils
|
||||||
|
username = get_user()
|
||||||
|
result = await push_utils.send_push(username, title, body, url)
|
||||||
|
if "error" in result:
|
||||||
|
return f"Push failed: {result['error']}"
|
||||||
|
return f"Push sent to {result['sent']} device(s) for {username} (pruned {result['pruned']} stale)."
|
||||||
|
|
||||||
|
|
||||||
async def nc_talk_send(message: str) -> str:
|
async def nc_talk_send(message: str) -> str:
|
||||||
"""Send a message to the user via their configured notification channel.
|
"""Send a message to the user via their configured notification channel.
|
||||||
|
|
||||||
@@ -84,6 +94,24 @@ async def nc_talk_send(message: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
DECLARATIONS = [
|
DECLARATIONS = [
|
||||||
|
types.FunctionDeclaration(
|
||||||
|
name="web_push",
|
||||||
|
description=(
|
||||||
|
"Send a browser push notification to the current user. Works even when the "
|
||||||
|
"Cortex tab is not open. Use for completing long tasks, reminders that fire "
|
||||||
|
"in the background, or anything the user should see immediately. "
|
||||||
|
"url is optional — if set, clicking the notification opens that URL."
|
||||||
|
),
|
||||||
|
parameters=types.Schema(
|
||||||
|
type=types.Type.OBJECT,
|
||||||
|
properties={
|
||||||
|
"title": types.Schema(type=types.Type.STRING, description="Notification title (short)"),
|
||||||
|
"body": types.Schema(type=types.Type.STRING, description="Notification body text"),
|
||||||
|
"url": types.Schema(type=types.Type.STRING, description="Optional URL to open on click"),
|
||||||
|
},
|
||||||
|
required=["title", "body"],
|
||||||
|
),
|
||||||
|
),
|
||||||
types.FunctionDeclaration(
|
types.FunctionDeclaration(
|
||||||
name="email_send",
|
name="email_send",
|
||||||
description=(
|
description=(
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ See `ARCH__Intelligence_Layer.md` for full design.
|
|||||||
- [x] Tool: `ae_journal_entry_disable` — soft-delete via enable=false — 2026-04-28
|
- [x] Tool: `ae_journal_entry_disable` — soft-delete via enable=false — 2026-04-28
|
||||||
- [x] Tool: `ae_journal_entry_append` — read→append timestamped section→write (running logs) — 2026-04-28
|
- [x] Tool: `ae_journal_entry_append` — read→append timestamped section→write (running logs) — 2026-04-28
|
||||||
- [x] Tool: `ae_journal_entry_prepend` — read→prepend timestamped section→write (newest-first logs) — 2026-04-28
|
- [x] Tool: `ae_journal_entry_prepend` — read→prepend timestamped section→write (newest-first logs) — 2026-04-28
|
||||||
- [ ] Import script: walk a markdown directory, chunk by H2 section, create entries
|
- [x] Import script: walk a markdown directory, chunk by H2 section, create entries — 2026-05-05
|
||||||
- [ ] Target: markdown files from `~/DgrZone_Nextcloud/` and `~/OSIT_Nextcloud/`
|
- [x] Target: markdown files from `~/DgrZone_Nextcloud/` and `~/OSIT_Nextcloud/` — 2026-05-05
|
||||||
- [ ] Tag strategy: source path, date, topic tags from frontmatter or filename
|
- [x] Tag strategy: source path, topic tags from path components — 2026-05-05
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ Read before finalising either design.
|
|||||||
### [Backend] API usage / cost tracking
|
### [Backend] API usage / cost tracking
|
||||||
Multi-user setup with real Gemini/Claude API costs. Track per-user token consumption
|
Multi-user setup with real Gemini/Claude API costs. Track per-user token consumption
|
||||||
so Scott can see who's spending what.
|
so Scott can see who's spending what.
|
||||||
- [ ] Count input + output tokens per `/chat` and `/orchestrate` call (all backends return usage)
|
- [x] Count input + output tokens — local backend (OpenAI `usage` field) + Gemini API (`usage_metadata`) — 2026-05-05
|
||||||
- [ ] Append to `home/{user}/usage.json` — daily buckets, per-model breakdown
|
- [x] Append to `home/{user}/usage.json` — daily buckets, per-model breakdown — 2026-05-05
|
||||||
- [ ] Expose via `/api/usage` endpoint; add a summary row to the Settings page
|
- [ ] Expose via `/api/usage` endpoint; add a summary row to the Settings page
|
||||||
- [ ] Optional: soft spending limit with a warning toast when exceeded
|
- [ ] Optional: soft spending limit with a warning toast when exceeded
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user