From 888e4658f58ed79cd66ecd5a5fe677f44a08987d Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 19:51:55 +0530 Subject: [PATCH 1/2] feat(quick): alarms, "what's next on my calendar" and desktop reminders on your phone Alarms are a quick command with a ringing banner and chime in the window; "what's next on my calendar" reads the connected Google Calendar with no model call and otherwise goes to the model; channels.deliver_reminders sends reminders and alarms set on the desktop to the delivery chats too. Closes #359. --- CHANGELOG.md | 7 + desktop/src/components/shell/AlarmBanner.tsx | 46 +++ desktop/src/components/shell/AppShell.tsx | 2 + .../src/features/channels/MessagingApps.tsx | 4 + desktop/src/features/settings/SchemaForm.tsx | 11 +- desktop/src/lib/events.ts | 2 + desktop/src/lib/types.ts | 4 +- desktop/src/stores/alarm.ts | 90 ++++++ docs/API.md | 55 +++- sentient/activity/timeline.py | 4 + sentient/activity/words.py | 3 +- sentient/agent/fastpath/__init__.py | 52 +++- sentient/agent/fastpath/base.py | 23 +- sentient/agent/fastpath/en.py | 251 ++++++++++++++-- sentient/channels/schema.sql | 2 +- sentient/channels/service.py | 20 +- sentient/config/schema.py | 10 +- sentient/reminders/schema.sql | 4 +- sentient/reminders/service.py | 11 +- sentient/reminders/tools.py | 48 +++- tests/channels/test_quick_commands.py | 35 +++ tests/test_quick_alarms_calendar.py | 271 ++++++++++++++++++ 22 files changed, 878 insertions(+), 77 deletions(-) create mode 100644 desktop/src/components/shell/AlarmBanner.tsx create mode 100644 desktop/src/stores/alarm.ts create mode 100644 tests/test_quick_alarms_calendar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f74b0395..0babb79ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ and versions follow [Semantic Versioning](https://semver.org/). turning off the alarm and opening a garage door or gate ask even when approvals are off. Tasks can start when a device changes, like "when the front door unlocks". Quick commands now work for lights too: "turn off the living room lights" happens at once, with no model, when it is clear which lights you mean (#175). +- **Alarms, "what's next on my calendar", and reminders on your phone.** "Wake me at 7" and "set an alarm for 6:30" + now work at once, without the model, like timers: Sentient says exactly when it will ring ("Alarm set for 7:00 AM + tomorrow."), and when it does, the window shows a banner with **Stop** and plays a chime until you stop it. "What + alarms do I have" and "turn off the gym alarm" work too. "What's next on my calendar" (or "what's my next meeting") + answers straight from your connected Google Calendar, also without the model. And a new switch on the Channels page, + **Reminders and alarms on your phone too**, sends the reminders and alarms you set on your computer to your paired + Telegram, Discord or WhatsApp chat as well. - **A safety check for skills and MCP servers from outside.** Skills brought over from Hermes, and skills you add with the new **Add from a link** button in Skills (a SKILL.md, or a GitHub folder that holds one), wait for your review with a plain safety report: where it came from, and whether it tries to send your data to a fixed address, diff --git a/desktop/src/components/shell/AlarmBanner.tsx b/desktop/src/components/shell/AlarmBanner.tsx new file mode 100644 index 000000000..0ec12a23d --- /dev/null +++ b/desktop/src/components/shell/AlarmBanner.tsx @@ -0,0 +1,46 @@ +/** #359 A ringing alarm: a banner with Stop while the chime plays (stores/alarm.ts). */ +import { IconAlarm, IconVolume, IconVolumeOff } from '@tabler/icons-react' +import { AnimatePresence, motion } from 'motion/react' +import { Button } from '@/components/ui' +import { useAlarm } from '@/stores/alarm' + +export function AlarmBanner() { + const ringing = useAlarm((s) => s.ringing) + const playing = useAlarm((s) => s.playing) + const stop = useAlarm((s) => s.stop) + + return ( + + {ringing && ( + +
+ + + + + {ringing.title}{' '} + {ringing.message} + + + {playing ? : } + {playing ? 'Ringing' : "Can't play sound"} + + +
+
+ )} +
+ ) +} diff --git a/desktop/src/components/shell/AppShell.tsx b/desktop/src/components/shell/AppShell.tsx index 02048cb2b..bee28ad3c 100644 --- a/desktop/src/components/shell/AppShell.tsx +++ b/desktop/src/components/shell/AppShell.tsx @@ -9,6 +9,7 @@ import { useActivityPing } from '@/hooks/useActivityPing' import { getBridge } from '@/lib/bridge' import { Button, Spinner } from '@/components/ui' import { useConnection } from '@/stores/connection' +import { AlarmBanner } from './AlarmBanner' import { CommandPalette } from './CommandPalette' import { StoppedBanner } from './StopControls' import { Sidebar } from './Sidebar' @@ -41,6 +42,7 @@ export function AppShell() {
+
diff --git a/desktop/src/features/channels/MessagingApps.tsx b/desktop/src/features/channels/MessagingApps.tsx index 31efcce4f..b96aca8c6 100644 --- a/desktop/src/features/channels/MessagingApps.tsx +++ b/desktop/src/features/channels/MessagingApps.tsx @@ -18,6 +18,7 @@ import { errorMessage, isNotImplemented } from '@/lib/api' import type { Channel, ChannelPairing, PairedChat } from '@/lib/types' import { cn, relativeTime } from '@/lib/utils' import { previewMode } from '@/features/devices/preview' +import { SchemaForm } from '@/features/settings/SchemaForm' import { useChannelActions, useChannels } from './hooks' import { ChannelTile } from './meta' import { WhatsAppLinkDialog } from './WhatsAppLink' @@ -75,6 +76,9 @@ export function MessagingApps() { setConnect(c)} onPair={() => setPair(c)} /> ))}
+
+ +
setConnect(null)} /> = { 'chat.show_thinking': { label: 'Show thinking', description: "Show the model's reasoning in a collapsible block." }, 'chat.auto_title': { label: 'Name chats automatically' }, 'chat.quick_commands': { - label: 'Quick timers and reminders', - description: 'Simple requests like "set a timer for 10 minutes" or "remind me at 6 pm to call Ravi" happen at once, without the model. English for now.' + label: 'Quick timers, reminders and alarms', + description: + 'Simple requests like "set a timer for 10 minutes", "remind me at 6 pm to call Ravi", "wake me at 7" or "what\'s next on my calendar" happen at once, without the model. English for now.' + }, + // channels (#359) + 'channels.deliver_reminders': { + label: 'Reminders and alarms on your phone too', + description: + 'Reminders and alarms you set on this computer or by voice also go off in your paired chats that get notifications. Ones you set in a chat always go off in that chat.' }, // memory 'memory.facts_top_k': { label: 'Facts recalled per message', unit: 'facts' }, diff --git a/desktop/src/lib/events.ts b/desktop/src/lib/events.ts index 043fc3b8f..b14717d01 100644 --- a/desktop/src/lib/events.ts +++ b/desktop/src/lib/events.ts @@ -50,6 +50,7 @@ import { upsertSubagent } from '@/features/chat/subagents' import { deviceKeys, removeDevice, upsertDevice } from '@/features/devices/hooks' import { useChat } from '@/stores/chat' import { useNotificationStore } from '@/stores/notifications' +import { isAlarm, useAlarm } from '@/stores/alarm' import { useUI } from '@/stores/ui' import { getBridge } from './bridge' import { approvalTarget, settledApprovalRun } from './nativeApproval' @@ -125,6 +126,7 @@ export function installDomainEvents(qc: QueryClient): () => void { ) useNotificationStore.getState().push(n) if (n.kind === 'brief') refreshBrief() + if (isAlarm(n)) useAlarm.getState().ring(n) // #359: it rings until Stop const title = n.title || 'Sentient' toast(title, { description: plain(n.message), diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 33b1177ec..0a3c8ccff 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -2902,7 +2902,7 @@ export interface FoldersUpdated { } // ---------------------------------------------------------------------------- §23 timers and reminders (#150) -export type ReminderKind = 'timer' | 'reminder' +export type ReminderKind = 'timer' | 'reminder' | 'alarm' export type ReminderStatus = 'scheduled' | 'fired' | 'cancelled' export interface Reminder { @@ -2911,7 +2911,7 @@ export interface Reminder { /** What to remind about ("call Ravi"); '' for a timer. */ message: string connector: '' | 'to' | 'about' | 'that' - /** A timer's name ("pasta"). */ + /** A timer's or an alarm's name ("pasta", "gym"). */ label: string /** A timer's length, or a reminder set "in 20 minutes". */ duration_s: number | null diff --git a/desktop/src/stores/alarm.ts b/desktop/src/stores/alarm.ts new file mode 100644 index 000000000..269c0e83f --- /dev/null +++ b/desktop/src/stores/alarm.ts @@ -0,0 +1,90 @@ +/** + * A ringing alarm (#359). An alarm's `reminder` notification (`payload.event = "alarm"`) starts a chime made with + * Web Audio, so no sound file ships with the app. It rings until Stop, or for `MAX_RING_MS` at most. + */ +import { create } from 'zustand' +import type { Notification } from '@/lib/types' + +const MAX_RING_MS = 3 * 60_000 +const PATTERN_MS = 1400 + +export interface RingingAlarm { + id: string + title: string + message: string + startedAt: number +} + +interface AlarmState { + ringing: RingingAlarm | null + /** True while the sound is really playing (the audio output started). */ + playing: boolean + ring: (n: Notification) => void + stop: () => void +} + +let ctx: AudioContext | null = null +let beat: number | undefined +let limit: number | undefined + +/** One "beep beep" of two short tones. */ +function chime(audio: AudioContext): void { + const now = audio.currentTime + for (const [offset, freq] of [[0, 880], [0.22, 1175]] as const) { + const osc = audio.createOscillator() + const gain = audio.createGain() + osc.type = 'sine' + osc.frequency.value = freq + gain.gain.setValueAtTime(0.0001, now + offset) + gain.gain.exponentialRampToValueAtTime(0.35, now + offset + 0.02) + gain.gain.exponentialRampToValueAtTime(0.0001, now + offset + 0.18) + osc.connect(gain).connect(audio.destination) + osc.start(now + offset) + osc.stop(now + offset + 0.2) + } +} + +function silence(): void { + window.clearInterval(beat) + window.clearTimeout(limit) + beat = limit = undefined + const audio = ctx + ctx = null + if (audio) void audio.close().catch(() => undefined) +} + +export function isAlarm(n: Notification): boolean { + return n.kind === 'reminder' && n.payload?.event === 'alarm' +} + +export const useAlarm = create((set, get) => ({ + ringing: null, + playing: false, + ring: (n) => { + silence() + set({ ringing: { id: n.id, title: n.title || 'Alarm', message: n.message, startedAt: Date.now() }, playing: false }) + try { + const audio = new AudioContext() + ctx = audio + const start = () => { + if (ctx !== audio) return + chime(audio) + beat = window.setInterval(() => chime(audio), PATTERN_MS) + set({ playing: audio.state === 'running' }) + audio.onstatechange = () => { + if (ctx === audio) set({ playing: audio.state === 'running' }) + } + } + void audio.resume().then(start, start) + } catch { + set({ playing: false }) // no audio output: the card and the notification still show + } + limit = window.setTimeout(() => { + if (get().ringing?.id === n.id) get().stop() + }, MAX_RING_MS) + }, + stop: () => { + silence() + set({ ringing: null, playing: false }) + } +})) diff --git a/docs/API.md b/docs/API.md index aabfc6f1e..28bdb4172 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2331,7 +2331,9 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a The Daily Brief (`kind: "brief"`, `payload.status: "active"`) is sent as its sections and lines, with links. A "Make this a rule?" proposal (`rule_proposal.updated`, section 2) made in a paired chat goes back to that chat (whatever `deliver` says) with **Make it a rule** / **Not now** (`rp:a:` / `rp:d:`). - Toggles: `channels.deliver_task_results`, `deliver_plans`, `deliver_suggestions`, `deliver_subagents`, `deliver_briefs`. + Toggles: `channels.deliver_task_results`, `deliver_plans`, `deliver_suggestions`, `deliver_subagents`, `deliver_briefs`, + and `deliver_reminders` (off by default, #359): reminders and alarms set outside a paired chat (the window, voice) also go + to the delivery chats (section 23). - Answering a task's question by replying: the delivered question ends with "Tap an option, or reply to this message with your answer." (without options: "Reply to this message with your answer."). The ids of the messages that carried the question are stored per chat in SQLite (`channel_questions`, kept 90 days), so this survives restarts. A text @@ -2380,8 +2382,9 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a `channel.delivery` `{id, channel, chat_id, title, status: "delivered"|"failed", ref}`: a notification (or a background helper's summary) was sent to a paired chat, or sending it failed (#345). Each is kept in `channel_deliveries` (`id, channel, chat_id, chat_label, title` (the message's first line), `what` (`task|question|approval|plan|brief|suggestion| - subagent|other`), `reason` (`chosen`: the task picked the chat; `default`: the chat's delivery is on; `chat`: a helper - started in that chat), `status`, `error` (a plain sentence, no token), `ref` (JSON: the task, chat or notification it + subagent|reminder|other`), `reason` (`chosen`: the task picked the chat; `default`: the chat's delivery is on; `chat`: a + helper started in that chat; `reminder`: a timer, reminder or alarm set in that chat; `reminder_copy`: a reminder or + alarm set elsewhere, sent there by `channels.deliver_reminders`), `status`, `error` (a plain sentence, no token), `ref` (JSON: the task, chat or notification it came from), `created_at`; kept 365 days) for the Activity timeline (section 21). ## 15. Knowing the user (owner: memory) @@ -3164,10 +3167,11 @@ searched). `progress` counts the new or changed files of the running scan. Settings > Folders lists them with their status and counts, adds one with the folder picker, removes one (with a confirmation), checks again, edits the folder's ignore patterns and shows the `folders` config section. -## 23. Timers, reminders and quick commands (owner: reminders and core, #150) +## 23. Timers, reminders, alarms and quick commands (owner: reminders and core, #150, #359) **Quick commands.** Before a chat turn calls the model, `Agent.run_turn` asks `sentient.agent.fastpath.plan`. When the -message is *only* a plain timer, reminder or (with Home Assistant connected) light command, it is done at once with no model call, on every surface (the +message is *only* a plain timer, reminder, alarm or (with Home Assistant connected) light command, or "what's next +on my calendar", it is done at once with no model call, on every surface (the window, voice and devices, Telegram, Discord and WhatsApp): the turn makes the same tool calls the model would (`tool_call` and `tool_result` events, kept in the chat like any other), says a short confirmation (`text_delta`, then `done`), and the engine logs `quick command: () done in N ms with no model call`. Anything else, @@ -3179,8 +3183,21 @@ language under `sentient/agent/fastpath/`, tried for `assistant.language` and th | timer | "set a timer for 10 minutes", "start a 10 minute timer", "set a pasta timer for 8 minutes", "timer for 90 seconds", "set a timer for half an hour" | no length ("set a timer"), over 24 hours, anything more in the message | | reminder | "remind me at 6 pm to call Ravi", "remind me to call Ravi at 6 pm", "remind me in 20 minutes", "remind me tomorrow at 9 am to pay rent", "remind me tonight at 8 to call mom", "remind me at 18:00 to leave" | a clock time without AM or PM ("at 6"), a repeating wish ("every day"), a day name, "today" at a time that has passed, a time missing ("tonight") | | list | "what timers do I have", "how much time is left", "what are my reminders" | | -| cancel | "cancel the timer", "stop the pasta timer", "cancel the 10 minute timer", "cancel my reminder to stretch", "cancel all timers" | two or more match and "all" was not said; nothing matches the words given; a plural without "all" | +| cancel | "cancel the timer", "stop the pasta timer", "cancel the 10 minute timer", "cancel my reminder to stretch", "cancel all timers", "turn off the gym alarm" | two or more match and "all" was not said; nothing matches the words given; a plural without "all" | | home (#175) | "turn off the living room lights", "turn on the bedroom lamp", "living room lights off", "turn the lights in the kitchen off", "switch off all the lights", "dim the living room lights to 30%", "turn on the coffee maker" | Home Assistant not connected or not reachable; the words name no room or device exactly (one light named that, the lights of the room named that, or the lights whose names hold every word); "the bedroom light" when the bedroom has two; "the lights" without a room or "all"; a device left out by the entity picker; locks, alarms, doors, garage doors and gates, and switches that sound like one (never quick); a question ("is the porch light on") | +| alarm (#359) | "wake me at 7", "wake me up at 6:30 am", "set an alarm for 6:30", "set an alarm for 6:30 tomorrow", "set a 6:30 alarm", "set an alarm for 7 called gym", "wake me up in 20 minutes" | a repeating wish, a day name, "today" at a time that has passed, "tomorrow morning" | +| calendar (#359) | "what's next on my calendar", "what's my next meeting", "when is my next meeting", "what do I have next" | no calendar connected; anything more ("... and email it to Ravi"), another day ("what's on my calendar tomorrow"), a bare "what's next" | + +An alarm is the one place a clock time without AM or PM is fine, as on a phone: "set an alarm for 6:30" rings at the +next 6:30, "wake me at 7" at the next 7 before 7 PM (so 7 AM; a nap "wake me at 4" in the afternoon is 4 PM), and with +"tomorrow" the morning one. The confirmation always says which ("Alarm set for 7:00 AM tomorrow."), and the list +("what alarms do I have", "when is my alarm") and cancel commands work for alarms too. + +"What's next on my calendar" calls `gcal_list_events` (risk `read`, from now to 7 days ahead, 10 events) when Google +Calendar is connected, and answers from the events with no model: an event happening now ("Right now: Standup, until +3:15 PM."), the next one ("Next on your calendar: Design review with Maya Rao, today at 4:30 PM (Room 4).") and the one +after it on the same day, or "Nothing on your calendar in the next 7 days." With no calendar connected, or the tool +behind an Ask or Never rule, the model answers (it may use another calendar, or say to connect one). "Hey Sentient", "please", "can you" and the assistant's own name around a command are fine. A quick command never sends, buys or deletes anything ("delete the timer" goes to the model). It runs only when its tool would run without @@ -3192,19 +3209,24 @@ level) once for every device it names, and says "Turned off the living room ligh `home_assistant`, or a Read only connection send it to the model. Not used for a message with attachments. Turned off with `chat.quick_commands`. -**Timers and reminders** (`sentient/reminders/`, table `reminders`). **Reminder** -`{id, kind: "timer"|"reminder", message, connector: ""|"to"|"about"|"that", label, duration_s, fire_at, status: +**Timers, reminders and alarms** (`sentient/reminders/`, table `reminders`). **Reminder** +`{id, kind: "timer"|"reminder"|"alarm", message, connector: ""|"to"|"about"|"that", label, duration_s, fire_at, status: "scheduled"|"fired"|"cancelled", session_id, channel, language, source: "quick"|"chat"|"api", created_at, fired_at, cancelled_at, notification_id}`. `fire_at` is UTC; a clock time is read in the user's timezone (`assistant.timezone`). `duration_s` is a timer's length or a reminder's "in 20 minutes". `session_id` is the chat it -was set in (null in an incognito chat). A timer runs 1 second to 24 hours; a reminder can be up to a year ahead. +was set in (null in an incognito chat). A timer runs 1 second to 24 hours; a reminder or alarm can be up to a year +ahead. An alarm has no `message`; its `label` names it ("gym"). When one comes due, the engine sends a `reminder` notification (`notification.new`): title "Timer done" (message "Your -10 minute timer is done.") or "Reminder" (message: what to remind about), payload `{event: "timer"|"reminder", -reminder_id, fire_at, late, channel, session_id?}`. One that came due while Sentient was not running goes off when it +10 minute timer is done."), "Reminder" (message: what to remind about) or "Alarm" / "Alarm: gym" (message "It's +7:00 AM.", or when late "Your 7:00 AM alarm went off while Sentient wasn't running."), payload `{event: +"timer"|"reminder"|"alarm", reminder_id, fire_at, late, channel, session_id?}`. The window rings for an alarm: a +banner with **Stop** and a chime (Web Audio, no sound file) that plays until Stop, for 3 minutes at most. One that came due while Sentient was not running goes off when it starts, and says when it was due (`late: true`). A reminder set in a Telegram, Discord or WhatsApp chat is also sent -to that chat (the delivery is listed on the Activity timeline as "A timer or reminder you set"); one set anywhere -else stays on the desktop. Firing never asks the model and Stop everything does not hold it back. Fired and cancelled +to that chat (the delivery is listed on the Activity timeline as "A timer, reminder or alarm you set"); one set +anywhere else stays on the desktop, unless `channels.deliver_reminders` is on (#359, off by default; "Reminders and +alarms on your phone too" on the Channels page): then a reminder or alarm set in the window or by voice also goes to +every paired chat whose delivery is on (delivery `reason: "reminder_copy"`). Timers always stay where they were set. Firing never asks the model and Stop everything does not hold it back. Fired and cancelled ones are forgotten after 30 days. ### Tools (plugin `reminders`, "Timers & reminders", all `internal`) @@ -3212,7 +3234,10 @@ ones are forgotten after 30 days. - `set_reminder(message = "", at = "", in_minutes = 0, connector = "to")` (risk `write`) → `{status, reminder}`. `at` is a local "YYYY-MM-DD HH:MM" (or ISO with an offset). Repeating reminders are tasks (`create_task_from_prompt`). -- `list_reminders(kind = "any")` (risk `read`) → `{status, reminders}`, soonest first. +- `set_alarm(at = "", in_minutes = 0, label = "")` (risk `write`, #359) → `{status, reminder}`. `at` as for + `set_reminder`. +- `list_reminders(kind = "any")` (risk `read`; `kind` "timer", "reminder", "alarm" or "any") → `{status, reminders}`, + soonest first. - `cancel_reminder(reminder_id)` (risk `write`) → `{status, reminder}`. Each `reminder` in a result also has `fire_at_local` and `seconds_left`. @@ -3220,7 +3245,7 @@ Each `reminder` in a result also has `fire_at_local` and `seconds_left`. ### REST - `GET /api/reminders?status=scheduled|fired|cancelled|all&limit=100` → `{reminders: [Reminder]}`; scheduled soonest first, others newest first. -- `POST /api/reminders` `{kind: "timer"|"reminder", minutes?, at?, message?, label?}` → Reminder (`source: "api"`). +- `POST /api/reminders` `{kind: "timer"|"reminder"|"alarm", minutes?, at?, message?, label?}` → Reminder (`source: "api"`). 400 with a plain `detail` when it can't be set. - `POST /api/reminders/{id}/cancel` → Reminder. 404 when unknown, 409 when it already went off or was cancelled. - Event `reminder.updated` (section 1). The Activity timeline (section 21) lists them under `reminders`. diff --git a/sentient/activity/timeline.py b/sentient/activity/timeline.py index 3efe3629e..192571c61 100644 --- a/sentient/activity/timeline.py +++ b/sentient/activity/timeline.py @@ -824,6 +824,8 @@ def build(row: Any) -> Item: fired = row["status"] == "fired" if row["kind"] == "timer": title = f"Your {what} went off" if fired else f"Cancelled your {what}" + elif row["kind"] == "alarm": + title = f"Your {what} rang" if fired else f"Cancelled your {what}" else: message = words.short(row["message"], 80) title = (f"Reminded you: {message}" if message else "Reminded you") if fired else \ @@ -1243,6 +1245,8 @@ async def _reminders_upcoming(self, until: datetime, tz: tzinfo) -> list[Item]: continue if r["kind"] == "timer": title = f"Your {words.reminder_name(r)} ends" + elif r["kind"] == "alarm": + title = f"Your {words.reminder_name(r)} rings" else: message = words.short(r["message"], 80) title = f"Will remind you: {message}" if message else "Will remind you" diff --git a/sentient/activity/words.py b/sentient/activity/words.py index 7ab2247b4..a3be43dea 100644 --- a/sentient/activity/words.py +++ b/sentient/activity/words.py @@ -27,11 +27,12 @@ "default": "That chat gets Sentient's notifications", "chat": "A helper you started in that chat finished", "reminder": "You set it in that chat", + "reminder_copy": "Your reminders and alarms go to this chat too", } DELIVERY_WHAT = { "task": "A task update", "question": "A question from a task", "approval": "An approval request from a task", "plan": "A plan waiting for your OK", "brief": "Your brief", "suggestion": "A suggestion", - "subagent": "Background work that finished", "reminder": "A timer or reminder you set", + "subagent": "Background work that finished", "reminder": "A timer, reminder or alarm you set", } WINDOW_PHRASES = {"morning": "in the morning", "afternoon": "in the afternoon", "evening": "in the evening", "night": "at night"} diff --git a/sentient/agent/fastpath/__init__.py b/sentient/agent/fastpath/__init__.py index facaf36dd..0ec703e9f 100644 --- a/sentient/agent/fastpath/__init__.py +++ b/sentient/agent/fastpath/__init__.py @@ -1,5 +1,5 @@ -"""Quick commands (#150): timers, reminders and, with Home Assistant connected, lights (#175), done at once without -the model. +"""Quick commands (#150, #359): timers, reminders, alarms, "what's next on my calendar" and, with Home Assistant +connected, lights (#175), done at once without the model. ``Agent.run_turn`` asks ``plan`` first. When a message is *only* a plain timer or reminder command ("set a timer for 10 minutes", "remind me at 6 pm to call Ravi", "cancel the pasta timer", "what timers do I have"), the plan holds the @@ -7,10 +7,11 @@ with no model call: it takes milliseconds on every surface (chat, voice, Telegram, Discord, WhatsApp). Anything else, or anything that could mean two things, returns None and goes to the model unchanged. -A quick command never sends, buys or deletes anything: it only starts, lists or cancels Sentient's own timers and -reminders, or turns lights and switches on and off ("turn off the living room lights", "dim the bedroom lamp to 30%") +A quick command never sends, buys or deletes anything: it only starts, lists or cancels Sentient's own timers, +reminders and alarms, reads the connected calendar (``gcal_list_events``; with no calendar connected the model +answers), or turns lights and switches on and off ("turn off the living room lights", "dim the bedroom lamp to 30%") when the words name exactly which ones (``IntegrationManager.home_devices``). Locks, alarms, garage doors and anything -that sounds like one never go through a quick command. It runs only when its tool would run without asking +that sounds like one never go through a home command. It runs only when its tool would run without asking (approvals mode, "Ask" and "Never" rules); a home command is the user's own words for exactly that change, so in approvals mode "ask" those words are the yes (``ApprovalBroker.quick_runs``, ``QuickPlan.asked``). Otherwise the model handles the message and the usual approval card appears. @@ -27,16 +28,18 @@ from typing import TYPE_CHECKING, Any from sentient.agent.fastpath import en -from sentient.agent.fastpath.base import ANY, REMINDER, TIMER, Command, Language +from sentient.agent.fastpath.base import ALARM, ANY, REMINDER, TIMER, Command, Language from sentient.llm.provider import ToolCall from sentient.store.db import new_id if TYPE_CHECKING: # pragma: no cover from sentient.app import SentientApp -__all__ = ["ANY", "LANGUAGES", "REMINDER", "TIMER", "Command", "Language", "QuickPlan", "language", "parse", "plan"] +__all__ = ["ALARM", "ANY", "CALENDAR_DAYS", "LANGUAGES", "REMINDER", "TIMER", "Command", "Language", "QuickPlan", "language", "parse", "plan"] LANGUAGES: dict[str, Language] = {"en": en} # type: ignore[dict-item] +CALENDAR_DAYS = 7 # "what's next on my calendar" looks this far ahead +CALENDAR_PLUGIN, CALENDAR_TOOL = "gcalendar", "gcal_list_events" def language(code: str | None) -> Language: @@ -112,9 +115,11 @@ async def plan(app: SentientApp, text: str, *, now: datetime | None = None) -> Q cmd, pack = found if cmd.action == "home": return await _home(app, cmd, pack, local) + out = QuickPlan(cmd, pack, local) + if cmd.action == "calendar_next": + return await _calendar_plan(app, out) if reminders is None: return None - out = QuickPlan(cmd, pack, local) if cmd.action == "timer": out.calls = [_call("set_timer", seconds=int(cmd.seconds or 0), label=cmd.label)] out.reply = lambda results: pack.timer_set(float(_reminder_of(results[0][0]).get("duration_s") or cmd.seconds or 0), @@ -127,12 +132,22 @@ async def plan(app: SentientApp, text: str, *, now: datetime | None = None) -> Q args["at"] = cmd.at.isoformat() out.calls = [_call("set_reminder", **args)] out.reply = lambda results: pack.reminder_set(_reminder_of(results[0][0]), local) + elif cmd.action == "alarm": + alarm: dict[str, Any] = {"label": cmd.label} + if cmd.seconds is not None: + alarm["in_minutes"] = cmd.seconds / 60 + elif cmd.at is not None: + alarm["at"] = cmd.at.isoformat() + out.calls = [_call("set_alarm", **alarm)] + out.reply = lambda results: pack.alarm_set(_reminder_of(results[0][0]), local) elif cmd.action == "list": out.calls = [_call("list_reminders", kind=cmd.target)] out.reply = lambda results: _listing(pack, results[0][0], cmd.target, local) elif cmd.action == "cancel": active = [r for r in await reminders.list() if cmd.target == ANY or r["kind"] == cmd.target] if not active: + if cmd.target == ALARM: + return None # "turn off the alarm" with none set may mean a home alarm: the model asks out.said = pack.nothing(cmd.target) return out chosen = [r for r in active if _matches(r, cmd.which, pack)] if cmd.which else active @@ -164,6 +179,27 @@ async def _home(app: SentientApp, cmd: Command, pack: Language, now: datetime) - return out +async def _calendar_plan(app: SentientApp, out: QuickPlan) -> QuickPlan | None: + """Read what's next from the connected calendar; None (the model answers) when no calendar is connected.""" + integrations = getattr(app, "integrations", None) + if integrations is None or app.registry.get(CALENDAR_TOOL) is None: + return None + try: + if not await integrations.is_connected(CALENDAR_PLUGIN): + return None + except Exception: + return None + pack, now = out.language, out.now + out.calls = [_call(CALENDAR_TOOL, max_results=10)] # from now to CALENDAR_DAYS ahead, soonest first + out.reply = lambda results: pack.calendar_next(_events(results[0][0]), now, CALENDAR_DAYS) + return out + + +def _events(res: Any) -> list[dict]: + items = res.get("events") if isinstance(res, dict) else None + return [e for e in items or [] if isinstance(e, dict)] + + def _listing(pack: Language, res: Any, target: str, now: datetime) -> str: items = res.get("reminders") if isinstance(res, dict) else None items = [r for r in items or [] if isinstance(r, dict)] diff --git a/sentient/agent/fastpath/base.py b/sentient/agent/fastpath/base.py index ab19ccd74..b42fac4e1 100644 --- a/sentient/agent/fastpath/base.py +++ b/sentient/agent/fastpath/base.py @@ -13,20 +13,20 @@ from datetime import datetime from typing import Protocol -# what a command is about: "timer", "reminder", or "any" (both) for listing and cancelling -TIMER, REMINDER, ANY = "timer", "reminder", "any" +# what a command is about: "timer", "reminder", "alarm", or "any" (all of them) for listing and cancelling +TIMER, REMINDER, ALARM, ANY = "timer", "reminder", "alarm", "any" # the kinds of home device a quick command can name (#175): lights (and lamps, bulbs) or switches (and plugs) LIGHT, SWITCH = "light", "switch" @dataclass class Command: - action: str # "timer" | "reminder" | "cancel" | "list" | "home" - seconds: float | None = None # a timer's length, or a reminder "in 20 minutes" - at: datetime | None = None # a reminder at a clock time (aware, in the user's timezone) + action: str # "timer" | "reminder" | "alarm" | "cancel" | "list" | "home" | "calendar_next" + seconds: float | None = None # a timer's length, or a reminder or alarm "in 20 minutes" + at: datetime | None = None # a reminder or alarm at a clock time (aware, in the user's timezone) message: str = "" # what to remind about ("call Ravi") connector: str = "" # how the message was asked for: "to", "about", "that" or "" (none) - label: str = "" # a timer's name ("pasta") + label: str = "" # a timer's or an alarm's name ("pasta", "gym") target: str = ANY # cancel / list: TIMER, REMINDER or ANY which: str = "" # cancel: words naming which one ("pasta", "10 minute", "stretch"); home: the device # as the user named it ("living room lights", "bedroom lamp") @@ -55,6 +55,13 @@ def timer_set(self, seconds: float, label: str) -> str: ... def reminder_set(self, reminder: dict, now: datetime) -> str: ... + def alarm_set(self, reminder: dict, now: datetime) -> str: ... + + def calendar_next(self, events: list[dict], now: datetime, days: int) -> str: + """What's next on the calendar, from ``gcal_list_events`` events (soonest first, ``now`` aware, in the user's + timezone); ``days`` is how far ahead was looked.""" + ... + def cancelled(self, reminders: list[dict]) -> str: ... def nothing(self, target: str) -> str: ... @@ -74,3 +81,7 @@ def timer_done(self, reminder: dict) -> tuple[str, str]: def reminder_due(self, reminder: dict, late_at: datetime | None) -> tuple[str, str]: """(title, body) of a reminder's notification; ``late_at`` is when it was due, when it fires late.""" ... + + def alarm_due(self, reminder: dict, at: datetime, late: bool) -> tuple[str, str]: + """(title, body) of an alarm's notification; ``at`` is when it was set for, in the user's timezone.""" + ... diff --git a/sentient/agent/fastpath/en.py b/sentient/agent/fastpath/en.py index b935833cf..6ba05bff3 100644 --- a/sentient/agent/fastpath/en.py +++ b/sentient/agent/fastpath/en.py @@ -1,5 +1,6 @@ -"""English quick commands (#150): timers, one-off reminders, and listing or cancelling them; with Home Assistant -connected, also lights and switches (#175): "turn off the living room lights", "dim the bedroom lamp to 30%". +"""English quick commands (#150, #359): timers, one-off reminders and alarms, listing or cancelling them, and what's +next on the calendar; with Home Assistant connected, also lights and switches (#175): "turn off the living room +lights", "dim the bedroom lamp to 30%". The grammar is deliberately narrow. A message must be *only* the command (with "please", "hey Sentient" and the like around it); anything else, or anything that could mean two things, is left to the model: @@ -7,6 +8,9 @@ - a clock time without AM or PM ("at 6") is ambiguous, unless it is written as 24-hour time ("18:00", "06:30"); - a repeating wish ("every day", "each Monday") is a task, not a one-off reminder; - "today at 9 AM" when 9 AM has passed, a day name, "tonight" without a time, "in a bit" all go to the model. + +An alarm is the exception to the AM/PM rule, as on a phone: "set an alarm for 6:30" rings at the next 6:30, and +"wake me at 7" at the next 7 that is not in the evening. The confirmation always says which one. """ from __future__ import annotations @@ -15,7 +19,7 @@ from collections.abc import Sequence from datetime import datetime, time, timedelta -from sentient.agent.fastpath.base import ANY, LIGHT, REMINDER, SWITCH, TIMER, Command +from sentient.agent.fastpath.base import ALARM, ANY, LIGHT, REMINDER, SWITCH, TIMER, Command code = "en" @@ -69,23 +73,25 @@ _CANCEL_VERB = r"(?:cancel|stop|end|turn\s+off|switch\s+off|dismiss|kill|clear|silence)" _CANCEL_A = re.compile( rf"^{_CANCEL_VERB}(?P\s+all(?:\s+of)?)?(?:\s+(?:the|my|that|this|those|these))?(?:\s+(?P.+?))?" - r"\s+(?Ptimers?|reminders?)$", re.I, + r"\s+(?Ptimers?|reminders?|alarms?)$", re.I, ) _CANCEL_B = re.compile( - rf"^{_CANCEL_VERB}(?:\s+(?:the|my|that|this))?\s+(?Ptimer|reminder)\s+(?:to|about|for|called|named|that)\s+" + rf"^{_CANCEL_VERB}(?:\s+(?:the|my|that|this))?\s+(?Ptimer|reminder|alarm)\s+(?:to|about|for|called|named|that)\s+" r"(?P.+)$", re.I, ) _LIST = [ re.compile( r"^(?:what|which|show(?:\s+me)?|list|tell\s+me|read(?:\s+me)?)(?:\s+(?:are|is))?(?:\s+all)?\s+(?:my|the)" - r"(?:\s+(?:active|current|running|upcoming))?\s+(?Ptimers?|reminders?)" + r"(?:\s+(?:active|current|running|upcoming))?\s+(?Ptimers?|reminders?|alarms?)" r"(?:\s+(?:(?:are|do\s+i\s+have|i\s+have)\s+)?(?:set|running|left|active|on))?$", re.I, ), re.compile( - r"^(?:do\s+i\s+have|have\s+i\s+got|are\s+there|is\s+there|any)(?:\s+(?:any|a))?\s+(?Ptimers?|reminders?)" + r"^(?:do\s+i\s+have|have\s+i\s+got|are\s+there|is\s+there|any)(?:\s+(?:any|a|an))?\s+(?Ptimers?|reminders?|alarms?)" r"(?:\s+(?:set|running|on|left))?$", re.I, ), - re.compile(r"^(?:what|which)\s+(?Ptimers?|reminders?)\s+(?:do\s+i\s+have|are\s+(?:set|running|on))$", re.I), + re.compile(r"^(?:what|which)\s+(?Ptimers?|reminders?|alarms?)\s+(?:do\s+i\s+have|are\s+(?:set|running|on))$", + re.I), + re.compile(r"^(?:when(?:'s|\s+is)|what\s+time\s+is)\s+my\s+(?:next\s+)?(?Palarm)(?:\s+set(?:\s+for)?)?$", re.I), re.compile(r"^how\s+(?:much\s+time|long)(?:\s+(?:is|do\s+i\s+have|have\s+i\s+got))?\s+left" r"(?:\s+on\s+(?:my|the)\s+(?Ptimer))?$", re.I), re.compile(r"^(?:what's|what\s+is)\s+(?:left|the\s+time\s+left|remaining)\s+on\s+(?:my|the)\s+(?Ptimer)$", @@ -97,6 +103,32 @@ ) +_ALARM_A = re.compile( + r"^(?:(?:set|create|make|add|put\s+on|start|schedule)\s+(?:me\s+)?(?:an?\s+|my\s+|the\s+)?)?alarm" + r"(?:\s+(?P.+))?$", re.I, +) +_ALARM_B = re.compile( # "set a 6:30 alarm", "set a 7 am alarm called gym" + r"^(?:set|create|make|add)\s+(?:me\s+)?(?:an?\s+)?(?P.+?)\s+alarm" + r"(?:\s+(?:called|named|labell?ed)\s+(?P