/** * Pulse & Cron Scheduler * Runs inside the supervisor, checks timing every 60 seconds, * and fires startBlobyAgentQuery for autonomous agent actions. */ import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { WORKSPACE_DIR } from '../shared/paths.js'; import { log } from '../shared/logger.js'; import { startBlobyAgentQuery } from './bloby-agent.js'; const PULSE_FILE = path.join(WORKSPACE_DIR, 'PULSE.json'); const CRONS_FILE = path.join(WORKSPACE_DIR, 'CRONS.json'); interface PulseConfig { enabled: boolean; intervalMinutes: number; quietHours: { start: string; end: string }; } interface CronConfig { id: string; schedule: string; task: string; enabled: boolean; oneShot?: boolean; paused?: boolean; // user-controlled via /api/crons/pause; scheduler skips when true. } interface SchedulerOpts { broadcastBloby: (type: string, data: any) => void; workerApi: (path: string, method?: string, body?: any) => Promise; restartBackend: () => void; getModel: () => string; /** Fired after a pulse/cron turn ends — the supervisor uses it to flush a queued self-update. */ onTurnComplete?: () => void; } // State let lastPulseTime = 0; const lastCronRuns = new Map(); let intervalHandle: ReturnType | null = null; let schedulerOpts: SchedulerOpts | null = null; export function readPulseConfig(): PulseConfig { try { const raw = fs.readFileSync(PULSE_FILE, 'utf-8'); const parsed = JSON.parse(raw); return { enabled: !!parsed.enabled, intervalMinutes: parsed.intervalMinutes || 30, quietHours: parsed.quietHours || { start: '23:00', end: '07:00' }, }; } catch { return { enabled: false, intervalMinutes: 30, quietHours: { start: '23:00', end: '07:00' } }; } } export function readCronsConfig(): CronConfig[] { try { const raw = fs.readFileSync(CRONS_FILE, 'utf-8'); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } /** Write crons back to disk (for oneShot cleanup) */ function writeCronsConfig(crons: CronConfig[]) { try { fs.writeFileSync(CRONS_FILE, JSON.stringify(crons, null, 2) + '\n', 'utf-8'); } catch (err: any) { log.warn(`[scheduler] Failed to write CRONS.json: ${err.message}`); } } function isInQuietHours(quietHours: { start: string; end: string }): boolean { const now = new Date(); const [startH, startM] = quietHours.start.split(':').map(Number); const [endH, endM] = quietHours.end.split(':').map(Number); const currentMinutes = now.getHours() * 60 + now.getMinutes(); const startMinutes = startH * 60 + startM; const endMinutes = endH * 60 + endM; if (startMinutes <= endMinutes) { return currentMinutes >= startMinutes && currentMinutes < endMinutes; } else { return currentMinutes >= startMinutes || currentMinutes < endMinutes; } } /** Check if a cron schedule has no future occurrences (expired one-shot) */ function cronIsExpired(schedule: string): boolean { try { const interval = CronExpressionParser.parse(schedule); interval.next(); // throws if no future occurrence return false; } catch { return true; } } function cronMatchesNow(schedule: string): boolean { try { const interval = CronExpressionParser.parse(schedule); const prev = interval.prev().toDate(); const now = new Date(); return ( prev.getFullYear() === now.getFullYear() && prev.getMonth() === now.getMonth() && prev.getDate() === now.getDate() && prev.getHours() === now.getHours() && prev.getMinutes() === now.getMinutes() ); } catch { return false; } } function triggerAgent(prompt: string, label: string, onComplete?: () => void) { if (!schedulerOpts) return; const { broadcastBloby, workerApi, restartBackend, getModel, onTurnComplete } = schedulerOpts; const timestamp = Date.now(); const convId = label.startsWith('pulse') ? `pulse-${timestamp}` : `cron-${label}-${timestamp}`; const model = getModel(); log.info(`[scheduler] ${label} triggered — starting agent query`); (async () => { // Use the user's current conversation (or create one) let dbConvId: string | undefined; try { const ctx = await workerApi('/api/context/current'); if (ctx.conversationId) { dbConvId = ctx.conversationId; } else { const conv = await workerApi('/api/conversations', 'POST', { title: `Chat`, model, }); dbConvId = conv.id; await workerApi('/api/context/set', 'POST', { conversationId: dbConvId }); } } catch (err: any) { log.warn(`[scheduler] Failed to get/create conversation for ${label}: ${err.message}`); } // Fetch bot name for push notification title let botName = 'Bloby'; try { const status = await workerApi('/api/onboard/status'); if (status.agentName) botName = status.agentName; } catch {} let fullResponse = ''; startBlobyAgentQuery(convId, prompt, model, (type, eventData) => { if (type === 'bot:response') { fullResponse = eventData.content || ''; } if (type === 'bot:done') { // ── Mac channel push (server-initiated) ── // When the `mac` skill is installed, it instructs the agent to wrap any // Mac-bound pulse/cron output in (a spoken line + // an optional /). Forward each block's inner // content over the chat WebSocket as an unsolicited `mac:push` frame — // the Morphy Mac app renders it (notch card + TTS) without the user // having pushed to talk. If the skill isn't installed the agent never // emits this tag, so nothing is pushed and non-Mac users carry no weight. if (fullResponse) { const macPushRegex = /([\s\S]*?)<\/mac_push>/g; let macMatch; while ((macMatch = macPushRegex.exec(fullResponse)) !== null) { const macContent = macMatch[1].trim(); if (macContent) { broadcastBloby('mac:push', { content: macContent }); log.info(`[scheduler] Mac push broadcast (${macContent.length} chars)`); } } } // Extract blocks after agent turn completes if (fullResponse) { const messageRegex = /]*))?>(([\s\S]*?))<\/Message>/g; let match; while ((match = messageRegex.exec(fullResponse)) !== null) { const attrs = match[1] || ''; const messageContent = match[2].trim(); const titleMatch = attrs.match(/title="([^"]*)"/); log.info(`[scheduler] Agent message: ${messageContent.slice(0, 80)}...`); const msgTimestamp = new Date().toISOString(); // Save to the user's conversation in DB if (dbConvId) { workerApi(`/api/conversations/${dbConvId}/messages`, 'POST', { role: 'assistant', content: messageContent, meta: { model }, }).catch((err: any) => { log.warn(`[scheduler] DB persist error: ${err.message}`); }); } // Broadcast as a regular message to all connected clients broadcastBloby('chat:sync', { conversationId: dbConvId, message: { role: 'assistant', content: messageContent, timestamp: msgTimestamp }, }); // Send push notification for closed tabs / locked devices workerApi('/api/push/send', 'POST', { title: titleMatch?.[1] || botName, body: messageContent.slice(0, 200), tag: `bloby-${label}`, url: '/', }).then((r: any) => { log.info(`[scheduler] Push sent: ${r.sent}/${r.total}`); }).catch((err: any) => { log.warn(`[scheduler] Push send failed: ${err.message}`); }); } } log.info(`[scheduler] ${label} agent query complete`); if (eventData.usedFileTools) { log.info(`[scheduler] File tools used — restarting backend`); restartBackend(); } onTurnComplete?.(); // flush a queued self-update now this pulse/cron turn has ended onComplete?.(); } if (type === 'bot:error') { log.warn(`[scheduler] ${label} agent error: ${eventData.error}`); } }); })(); } function tick() { const now = Date.now(); // ── Pulse check ── const pulse = readPulseConfig(); if (pulse.enabled && !isInQuietHours(pulse.quietHours)) { const elapsed = now - lastPulseTime; const intervalMs = pulse.intervalMinutes * 60 * 1000; if (elapsed >= intervalMs) { lastPulseTime = now; triggerAgent('', 'pulse'); } } // ── Cron check ── const crons = readCronsConfig(); for (const cron of crons) { // `paused === true` (strict) so a missing/false flag from pre-pause CRONS.json never skips. // Checked before cronMatchesNow/lastCronRuns so a paused cron neither fires nor advances state. if (!cron.enabled || cron.paused === true || !cron.id || !cron.schedule) continue; const matches = cronMatchesNow(cron.schedule); const nowDate = new Date(); log.info(`[scheduler] Cron "${cron.id}" schedule="${cron.schedule}" matches=${matches} now=${nowDate.getHours()}:${String(nowDate.getMinutes()).padStart(2,'0')}`); if (matches) { const lastRun = lastCronRuns.get(cron.id) || 0; const oneMinuteAgo = now - 60_000; if (lastRun < oneMinuteAgo) { lastCronRuns.set(cron.id, now); // One-shots: defer removal until agent completes (so agent can still read CRONS.json) const onComplete = cron.oneShot ? () => { log.info(`[scheduler] Removing fired one-shot cron: ${cron.id}`); const fresh = readCronsConfig().filter((c) => c.id !== cron.id); writeCronsConfig(fresh); // Also remove the task file if it exists try { fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${cron.id}.md`)); } catch {} } : undefined; // Inject task file content if tasks/{id}.md exists let cronPrompt = `${cron.id}`; try { const taskFile = path.join(WORKSPACE_DIR, 'tasks', `${cron.id}.md`); const taskContent = fs.readFileSync(taskFile, 'utf-8').trim(); if (taskContent) { cronPrompt += `\n\n${taskContent}\n`; } } catch {} triggerAgent(cronPrompt, cron.id, onComplete); } } } // ── Cleanup: remove expired one-shots (schedule in the past, never gonna fire) ── if (crons.length > 0) { const cleaned = crons.filter((c) => { if (c.oneShot && cronIsExpired(c.schedule)) { log.info(`[scheduler] cronIsExpired=true for "${c.id}" schedule="${c.schedule}"`); log.info(`[scheduler] Removing expired one-shot cron: ${c.id}`); try { fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`)); } catch {} return false; } return true; }); if (cleaned.length !== crons.length) { writeCronsConfig(cleaned); } } } /** ISO of the next occurrence, or null if the schedule is unparseable / has no future occurrence. */ export function nextRunISO(schedule: string): string | null { try { // .next() throws both on a parse error and when there is no future occurrence — single parse. return CronExpressionParser.parse(schedule).next().toISOString(); } catch { return null; } } /** Humanize a cron expression for the settings UI; falls back to the raw expression when a field * is out of range or the pattern isn't one we can describe accurately (so the label never lies). */ export function describeCron(schedule: string): string { const parts = schedule.trim().split(/\s+/); if (parts.length !== 5) return schedule; // 6-field / @macro — show raw, don't lie const [min, hr, dom, mon, dow] = parts; const star = (s: string) => s === '*'; const two = (s: string) => String(s).padStart(2, '0'); const inRange = (s: string, lo: number, hi: number) => /^\d+$/.test(s) && Number(s) >= lo && Number(s) <= hi; const at = (inRange(hr, 0, 23) && inRange(min, 0, 59)) ? `${two(hr)}:${two(min)}` : null; // `*/N` only yields an even interval when N divides the field's range (60 min / 24 hr); otherwise // it wraps at the top of the range and the "every N" label would be wrong — fall through to raw. const everyMin = min.match(/^\*\/(\d+)$/); if (everyMin && Number(everyMin[1]) >= 1 && 60 % Number(everyMin[1]) === 0 && star(hr) && star(dom) && star(mon) && star(dow)) { return `Every ${everyMin[1]} minute${everyMin[1] === '1' ? '' : 's'}`; } const everyHr = hr.match(/^\*\/(\d+)$/); if (everyHr && Number(everyHr[1]) >= 1 && 24 % Number(everyHr[1]) === 0 && inRange(min, 0, 59) && star(dom) && star(mon) && star(dow)) { return `Every ${everyHr[1]} hour${everyHr[1] === '1' ? '' : 's'}${min === '0' ? '' : ` at :${two(min)}`}`; } if (at && star(dom) && star(mon) && star(dow)) return `Every day at ${at}`; const DOW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; if (at && star(dom) && star(mon) && /^[0-7]$/.test(dow)) return `Every ${DOW[Number(dow) % 7]} at ${at}`; // 7 = Sunday alias if (at && inRange(dom, 1, 31) && star(mon) && star(dow)) return `Monthly on day ${dom} at ${at}`; const MON = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; if (at && inRange(dom, 1, 31) && inRange(mon, 1, 12) && star(dow)) return `On ${MON[Number(mon)]} ${dom} at ${at}`; return schedule; } export function startScheduler(opts: SchedulerOpts) { schedulerOpts = opts; lastPulseTime = Date.now(); intervalHandle = setInterval(tick, 60_000); log.info('[scheduler] Started — checking every 60s'); } export function stopScheduler() { if (intervalHandle) { clearInterval(intervalHandle); intervalHandle = null; } schedulerOpts = null; log.info('[scheduler] Stopped'); }