/** * Scheduler — cron-like recurring tasks + notification support. * Simpler than full cron: supports interval_ms (recurring) + run_at (one-shot). * Inspired by devduck scheduler + tasks tools. */ import { tool } from '@strands-agents/sdk' import { z } from 'zod' import { get, set, del } from 'idb-keyval' const SCHED_KEY = 'careless-scheduler' export interface ScheduledJob { id: string name: string prompt: string createdAt: number /** One-shot timestamp (ms) */ runAt?: number /** Recurring interval */ intervalMs?: number /** Last fire time */ lastFiredAt?: number /** Fire count */ fireCount: number enabled: boolean /** If true, also trigger system notification when firing */ notify?: boolean } async function loadJobs(): Promise { const j = await get(SCHED_KEY) return Array.isArray(j) ? j : [] } async function saveJobs(jobs: ScheduledJob[]) { await set(SCHED_KEY, jobs) } /** Start the scheduler loop. Called once on app boot. */ let schedulerIntervalId: number | null = null export function startScheduler() { if (schedulerIntervalId) return const tick = async () => { const now = Date.now() const jobs = await loadJobs() let dirty = false for (const job of jobs) { if (!job.enabled) continue let shouldFire = false if (job.runAt && now >= job.runAt && !job.lastFiredAt) shouldFire = true else if (job.intervalMs) { const since = job.lastFiredAt ? now - job.lastFiredAt : now - job.createdAt if (since >= job.intervalMs) shouldFire = true } if (shouldFire) { job.lastFiredAt = now job.fireCount = (job.fireCount || 0) + 1 dirty = true window.dispatchEvent(new CustomEvent('careless:task-fire', { detail: job })) if (job.notify && 'Notification' in window && Notification.permission === 'granted') { try { new Notification('careless', { body: job.name + ': ' + job.prompt.slice(0, 140) }) } catch {} } // One-shot jobs disable themselves if (job.runAt && !job.intervalMs) job.enabled = false } } if (dirty) await saveJobs(jobs) } schedulerIntervalId = window.setInterval(tick, 5000) as any tick() } export const scheduleJobTool = tool({ name: 'scheduler_create', description: 'Schedule a recurring or one-shot task. Provide interval_ms for recurring, run_at (unix ms) for one-shot. The task prompt fires the agent when its time comes.', inputSchema: z.object({ name: z.string(), prompt: z.string(), interval_ms: z.number().optional(), run_at: z.number().optional(), notify: z.boolean().optional(), }), callback: async (input) => { if (!input.interval_ms && !input.run_at) { return JSON.stringify({ status: 'error', error: 'Must provide interval_ms or run_at' }) } const jobs = await loadJobs() const job: ScheduledJob = { id: 'job-' + Math.random().toString(36).slice(2, 10), name: input.name, prompt: input.prompt, createdAt: Date.now(), runAt: input.run_at, intervalMs: input.interval_ms, fireCount: 0, enabled: true, notify: input.notify, } jobs.push(job) await saveJobs(jobs) if (input.notify && 'Notification' in window && Notification.permission === 'default') { Notification.requestPermission() } return JSON.stringify({ status: 'created', job }) }, }) export const listJobsTool = tool({ name: 'scheduler_list', description: 'List all scheduled jobs.', inputSchema: z.object({}), callback: async () => { const jobs = await loadJobs() return JSON.stringify({ status: 'success', count: jobs.length, jobs }) }, }) export const deleteJobTool = tool({ name: 'scheduler_delete', description: 'Delete a scheduled job by ID.', inputSchema: z.object({ id: z.string() }), callback: async (input) => { const jobs = await loadJobs() const filtered = jobs.filter(j => j.id !== input.id) await saveJobs(filtered) return JSON.stringify({ status: 'deleted', remaining: filtered.length }) }, }) export const toggleJobTool = tool({ name: 'scheduler_toggle', description: 'Enable or disable a scheduled job.', inputSchema: z.object({ id: z.string(), enabled: z.boolean() }), callback: async (input) => { const jobs = await loadJobs() const j = jobs.find(j => j.id === input.id) if (!j) return JSON.stringify({ status: 'error', error: 'not found' }) j.enabled = input.enabled await saveJobs(jobs) return JSON.stringify({ status: 'updated', job: j }) }, }) export const notifyTool = tool({ name: 'notify_push', description: 'Send a push notification (uses Notification API). Requests permission if needed.', inputSchema: z.object({ title: z.string(), body: z.string().optional(), icon: z.string().optional(), vibrate: z.boolean().optional(), }), callback: async (input) => { if (!('Notification' in window)) return JSON.stringify({ status: 'unsupported' }) if (Notification.permission === 'default') { const perm = await Notification.requestPermission() if (perm !== 'granted') return JSON.stringify({ status: 'permission_denied', perm }) } if (Notification.permission !== 'granted') return JSON.stringify({ status: 'permission_denied' }) try { new Notification(input.title, { body: input.body, icon: input.icon }) if (input.vibrate && (navigator as any).vibrate) (navigator as any).vibrate([100, 50, 100]) return JSON.stringify({ status: 'sent' }) } catch (err: unknown) { return JSON.stringify({ status: 'error', error: (err as Error).message }) } }, }) export const SCHEDULER_TOOLS = [scheduleJobTool, listJobsTool, deleteJobTool, toggleJobTool, notifyTool]