diff --git a/agents/k8s/reminder-service-deployment.yaml b/agents/k8s/reminder-service-deployment.yaml new file mode 100644 index 0000000..14c1775 --- /dev/null +++ b/agents/k8s/reminder-service-deployment.yaml @@ -0,0 +1,99 @@ +--- +# PVC for persistent reminder storage +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: reminder-service-pvc + namespace: ai-agents +spec: + accessModes: [ReadWriteOnce] + storageClassName: longhorn + resources: + requests: + storage: 512Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reminder-service + namespace: ai-agents + labels: + app: reminder-service +spec: + replicas: 1 + selector: + matchLabels: + app: reminder-service + template: + metadata: + labels: + app: reminder-service + spec: + initContainers: + - name: git-clone + image: alpine/git + command: + - git + - clone + - http://192.168.8.248:3000/deepkoluguri/agentic-os.git + - /app + volumeMounts: + - name: app-volume + mountPath: /app + containers: + - name: reminder-service + image: node:18-alpine + workingDir: /app/reminder-service + command: + - sh + - -c + - npm install --omit=dev && node server.js + ports: + - containerPort: 6001 + env: + - name: DATA_FILE + value: /data/reminders.json + - name: PORT + value: "6001" + - name: GATEWAY_URL + value: http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message + - name: PERSONAL_NUM + value: "+14085505485" + - name: GROUP_URL + value: "https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX" + volumeMounts: + - name: app-volume + mountPath: /app + - name: data-volume + mountPath: /data + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi + readinessProbe: + httpGet: + path: /health + port: 6001 + initialDelaySeconds: 30 + periodSeconds: 15 + volumes: + - name: app-volume + emptyDir: {} + - name: data-volume + persistentVolumeClaim: + claimName: reminder-service-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: reminder-service + namespace: ai-agents +spec: + selector: + app: reminder-service + ports: + - port: 6001 + targetPort: 6001 diff --git a/interface/hq-dashboard/app/api/reminders/route.js b/interface/hq-dashboard/app/api/reminders/route.js new file mode 100644 index 0000000..3f46999 --- /dev/null +++ b/interface/hq-dashboard/app/api/reminders/route.js @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; + +const REMINDER_SVC = process.env.REMINDER_SERVICE_URL || + 'http://reminder-service.ai-agents.svc.cluster.local:6001'; + +// GET /api/reminders — list reminders (optional ?status=pending) +export async function GET(request) { + try { + const { searchParams } = new URL(request.url); + const status = searchParams.get('status') || ''; + const url = `${REMINDER_SVC}/reminders${status ? `?status=${status}` : ''}`; + const res = await fetch(url, { next: { revalidate: 0 } }); + const data = await res.json(); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} + +// POST /api/reminders/batch — schedule multiple at once +export async function POST(request) { + try { + const body = await request.json(); + const res = await fetch(`${REMINDER_SVC}/reminders/batch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} + +// DELETE /api/reminders?id=rem_xxx — cancel a reminder +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 }); + const res = await fetch(`${REMINDER_SVC}/reminders/${id}`, { method: 'DELETE' }); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/interface/hq-dashboard/app/api/schedule-reminder/route.js b/interface/hq-dashboard/app/api/schedule-reminder/route.js new file mode 100644 index 0000000..b3e24fa --- /dev/null +++ b/interface/hq-dashboard/app/api/schedule-reminder/route.js @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; + +const REMINDER_SVC = process.env.REMINDER_SERVICE_URL || + 'http://reminder-service.ai-agents.svc.cluster.local:6001'; + +export async function POST(request) { + try { + const body = await request.json(); + const res = await fetch(`${REMINDER_SVC}/reminders`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/interface/hq-dashboard/app/page.jsx b/interface/hq-dashboard/app/page.jsx index f167524..c5b1d73 100644 --- a/interface/hq-dashboard/app/page.jsx +++ b/interface/hq-dashboard/app/page.jsx @@ -36,7 +36,7 @@ export default function Page() { const [messages, setMessages] = useState([ { role: "assistant", - content: "Hello! I am Claw, your local agent. Try asking me:\n• `weather` — live weather for Mars, PA\n• `whatsapp me: your message` — send anything to your phone\n• `check k8s pods` — Kubernetes diagnostics\n• `diagnose proxmox container 102` — disk & resource check", + content: "Hello! I am Claw, your local agent. Try:\n• `weather` — live weather for Mars, PA\n• `whatsapp me: message` — send now to your phone\n• `remind me at 3pm: call doctor` — schedule for later\n• `remind me tomorrow at 6am: gym` — future reminders\n• Or give me a list with times and I'll schedule them all!", timestamp: "Just now" } ]); @@ -46,6 +46,7 @@ export default function Page() { const [weatherCard, setWeatherCard] = useState(null); const [weatherSending, setWeatherSending] = useState(false); const [weatherSent, setWeatherSent] = useState(false); + const [scheduledReminders, setScheduledReminders] = useState([]); // OpsAgent State const [pendingApprovals, setPendingApprovals] = useState([ @@ -151,6 +152,88 @@ export default function Page() { responseText = `❌ Couldn't send to WhatsApp: ${err.message}. Make sure the gateway is running.`; } + // ── Scheduled Reminder ───────────────────────────────────────────────────── + // Patterns: "remind me at X: msg", "schedule (whatsapp) at X: msg", + // "send to whatsapp at X: msg", multi-bullet lists + } else if ( + /(?:remind\s+me|schedule\s+(?:a\s+)?(?:whatsapp\s+)?(?:reminder|message)?)\s+(?:at|on|for|in|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday)/i.test(userText) || + /send\s+to\s+whatsapp\s+(?:at|in|tomorrow|on\s+)/i.test(userText) || + // multi-bullet list: "schedule these:\n- msg at 3pm\n- msg2 at 6am" + (/(?:schedule|remind\s+me)/i.test(userText) && userText.includes('\n')) + ) { + // Check for multi-item list (lines starting with - or •) + const lines = userText.split('\n').map(l => l.trim()).filter(Boolean); + const bulletLines = lines.filter(l => /^[-•*]\s+/.test(l) || (lines.length > 2 && l !== lines[0])); + const isBatch = bulletLines.length > 1; + + if (isBatch) { + // Batch scheduling + const items = []; + const skipped = []; + for (const line of bulletLines) { + const clean = line.replace(/^[-•*]\s*/, '').trim(); + // Try to split into message + time: "call doctor at 2pm" or "2pm: call doctor" + // Strategy: extract time, rest is the message + const sendAt = parseScheduleDateTime(clean); + if (!sendAt) { skipped.push(clean); continue; } + // Remove the time expression from the label + const label = clean.replace(/(at|by|on|for)?\s*\d{1,2}(?::\d{2})?\s*(am|pm)/gi, '').replace(/tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday/gi, '').replace(/in\s+\d+\s+(hour|hr|minute|min)s?/gi, '').trim().replace(/\s+/g, ' '); + const formatted = `⏰ *Claw Reminder*\n\n${label}\n\n_Scheduled for ${sendAt.toLocaleString('en-US', { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })} ET_`; + items.push({ message: formatted, sendAt: sendAt.toISOString(), label }); + } + + if (items.length === 0) { + responseText = `❌ I couldn't parse any valid times from your list. Make sure each item includes a time like "at 3pm", "tomorrow at 9am", or "in 2 hours".`; + } else { + setClawTerminalLogs(prev => [...prev, `[Claw] Scheduling ${items.length} reminders...`]); + try { + const res = await fetch('/api/reminders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }) }); + const data = await res.json(); + const lines2 = data.created?.map(c => `✅ "${c.label}" → ${formatReminderTime(c.sendAt)} ET`) || []; + if (skipped.length) lines2.push(...skipped.map(s => `⚠️ Skipped (no time): "${s}"`)); + responseText = `📅 Scheduled ${data.created?.length || 0} reminder(s):\n\n${lines2.join('\n')}`; + // Refresh reminder panel + fetch('/api/reminders?status=pending').then(r => r.json()).then(d => { if (Array.isArray(d)) setScheduledReminders(d); }); + setClawTerminalLogs(prev => [...prev, `[Claw] ✅ Batch scheduled: ${items.length} reminders`]); + } catch (err) { + responseText = `❌ Failed to schedule: ${err.message}`; + } + } + + } else { + // Single reminder + // Parse message after colon: "remind me at 3pm: call doctor" → msg = "call doctor" + const colonSplit = userText.match(/[:\u2014]\s*(.+)$/s); + const msgText = colonSplit ? colonSplit[1].trim() : userText; + const sendAt = parseScheduleDateTime(userText); + + if (!sendAt) { + responseText = `❌ I couldn't find a valid time in your message. Try: "remind me at 3pm: call doctor" or "remind me tomorrow at 9am: gym".`; + } else { + const etLabel = sendAt.toLocaleString('en-US', { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); + const waMsg = `⏰ *Claw Reminder*\n\n${msgText}\n\n_Scheduled for ${etLabel} ET_`; + setClawTerminalLogs(prev => [...prev, `[Claw] Scheduling: "${msgText}" → ${sendAt.toISOString()}`]); + try { + const res = await fetch('/api/schedule-reminder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: waMsg, sendAt: sendAt.toISOString(), label: msgText }), + }); + const data = await res.json(); + if (data.success) { + responseText = `✅ Reminder scheduled!\n\n📌 **"${msgText}"**\n🕐 Delivers at **${etLabel} ET** to your WhatsApp (+14085505485)`; + fetch('/api/reminders?status=pending').then(r => r.json()).then(d => { if (Array.isArray(d)) setScheduledReminders(d); }); + setClawTerminalLogs(prev => [...prev, `[Claw] ✅ Scheduled ID: ${data.id}`]); + } else { + throw new Error(data.error || 'Scheduling failed'); + } + } catch (err) { + responseText = `❌ Couldn't schedule: ${err.message}`; + setClawTerminalLogs(prev => [...prev, `[Claw] ❌ ${err.message}`]); + } + } + } + } else if (cleanInput.includes("weather")) { // Extract optional location override @@ -277,7 +360,108 @@ export default function Page() { } }; - // Approval Handlers + // Poll scheduled reminders every 30s when on claw tab + useEffect(() => { + if (activeTab !== 'claw') return; + const fetchReminders = async () => { + try { + const res = await fetch('/api/reminders?status=pending'); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data)) setScheduledReminders(data); + } + } catch { /* reminder service may not be up yet */ } + }; + fetchReminders(); + const interval = setInterval(fetchReminders, 30000); + return () => clearInterval(interval); + }, [activeTab]); + + // ── Natural-language date/time parser ──────────────────────────────────────── + const parseScheduleDateTime = (text) => { + const t = text.toLowerCase(); + const now = new Date(); + + // "in X hours/minutes" + const inMatch = t.match(/\bin\s+(\d+)\s+(hour|hr|minute|min)s?\b/); + if (inMatch) { + const n = parseInt(inMatch[1]); + const ms = inMatch[2].startsWith('h') ? n * 3600000 : n * 60000; + return new Date(now.getTime() + ms); + } + + // Extract time component: "3pm", "6:30am", "14:00" + let hours = null, minutes = 0; + const ampm = t.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/); + const h24 = t.match(/(\d{2}):(\d{2})(?!\s*(am|pm))/); + if (ampm) { + hours = parseInt(ampm[1]); + minutes = ampm[2] ? parseInt(ampm[2]) : 0; + if (ampm[3] === 'pm' && hours !== 12) hours += 12; + if (ampm[3] === 'am' && hours === 12) hours = 0; + } else if (h24) { + hours = parseInt(h24[1]); + minutes = parseInt(h24[2]); + } + + // Build date + let date = new Date(now); + let explicitDate = false; + + if (t.includes('tomorrow')) { + date.setDate(date.getDate() + 1); + explicitDate = true; + } else { + const days = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; + const dayIdx = days.findIndex(d => t.includes(d)); + if (dayIdx !== -1) { + const diff = ((dayIdx - now.getDay()) + 7) % 7 || 7; // next occurrence + date.setDate(date.getDate() + diff); + explicitDate = true; + } else { + // Named month: "May 30", "June 1" + const months = ['january','february','march','april','may','june','july','august','september','october','november','december']; + const shortM = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec']; + let mIdx = months.findIndex(m => t.includes(m)); + if (mIdx === -1) mIdx = shortM.findIndex(m => t.match(new RegExp('\\b' + m + '\\b'))); + if (mIdx !== -1) { + const dayNum = t.match(/(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+(\d{1,2})/i); + if (dayNum) { + date.setMonth(mIdx, parseInt(dayNum[1])); + if (date < now) date.setFullYear(date.getFullYear() + 1); + explicitDate = true; + } + } + } + } + + if (hours === null) return null; // no time found + + date.setHours(hours, minutes, 0, 0); + + // If the computed time is in the past and no explicit date was given, push to tomorrow + if (date <= now && !explicitDate) date.setDate(date.getDate() + 1); + + return date; + }; + + // ── Format a Date nicely for display ───────────────────────────────────────── + const formatReminderTime = (isoStr) => { + const d = new Date(isoStr); + return d.toLocaleString('en-US', { + timeZone: 'America/New_York', + weekday: 'short', month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true + }); + }; + + // ── Cancel a reminder ───────────────────────────────────────────────────────── + const handleCancelReminder = async (id) => { + try { + const res = await fetch(`/api/reminders?id=${id}`, { method: 'DELETE' }); + if (res.ok) setScheduledReminders(prev => prev.filter(r => r.id !== id)); + } catch { /* silent */ } + }; const handleApprove = async (id, action, target) => { setPendingApprovals(prev => prev.map(item => item.id === id ? { ...item, status: "approved" } : item)); setOpsLogs(prev => [...prev, `2026-05-29 01:14:10 [OpsAgent] RECEIVED APPROVAL SIGNAL for request ${id}`]); @@ -749,6 +933,7 @@ export default function Page() { {/* Claw Tool & Console Log Panel */}
+ {/* Terminal Console */}

@@ -756,7 +941,7 @@ export default function Page() {

-
+
{clawTerminalLogs.length === 0 ? (
Console logs will stream here as Claw calls CLI and API tools...
) : ( @@ -768,10 +953,70 @@ export default function Page() { )}
+ + {/* Scheduled Reminders Panel */} +
+
+

+ + Scheduled Reminders + {scheduledReminders.length > 0 && ( + + {scheduledReminders.length} + + )} +

+ auto-refreshes +
+ +
+ {scheduledReminders.length === 0 ? ( +
+ No reminders scheduled.
+ Try: "remind me at 3pm: call doctor" +
+ ) : ( + scheduledReminders.map((r) => ( +
+
+
+ {r.label} +
+
+ 🕐 {formatReminderTime(r.sendAt)} ET +
+
+ +
+ )) + )} +
+
)} + {/* TAB 3: OPSAGENT HUB */} {activeTab === "ops" && (
diff --git a/reminder-service/package.json b/reminder-service/package.json new file mode 100644 index 0000000..3c366fc --- /dev/null +++ b/reminder-service/package.json @@ -0,0 +1,13 @@ +{ + "name": "reminder-service", + "version": "1.0.0", + "description": "Scheduled WhatsApp reminder delivery service for Agentic OS", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "node-cron": "^3.0.3" + } +} diff --git a/reminder-service/server.js b/reminder-service/server.js new file mode 100644 index 0000000..3bbca9e --- /dev/null +++ b/reminder-service/server.js @@ -0,0 +1,182 @@ +const express = require('express'); +const cron = require('node-cron'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); + +const app = express(); +app.use(express.json()); + +const DATA_FILE = process.env.DATA_FILE || '/data/reminders.json'; +const GATEWAY = process.env.GATEWAY_URL || 'http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/send-message'; +const PERSONAL = process.env.PERSONAL_NUM || '+14085505485'; +const GROUP = process.env.GROUP_URL || 'https://chat.whatsapp.com/0Txfg7iZBbvIuvTbTqqgAX'; +const PORT = parseInt(process.env.PORT) || 6001; + +// ── Persistence ────────────────────────────────────────────────────────────── + +function load() { + try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } + catch { return []; } +} + +function save(reminders) { + fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true }); + fs.writeFileSync(DATA_FILE, JSON.stringify(reminders, null, 2)); +} + +// ── WhatsApp delivery ───────────────────────────────────────────────────────── + +function sendWhatsApp(to, message) { + return new Promise((resolve) => { + const body = JSON.stringify({ to, message }); + const url = new URL(GATEWAY); + const opts = { + hostname: url.hostname, + port: parseInt(url.port) || 80, + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } + }; + const req = http.request(opts, r => { + let d = ''; + r.on('data', c => d += c); + r.on('end', () => { + try { resolve(JSON.parse(d)); } + catch { resolve({ success: false, error: 'parse error' }); } + }); + }); + req.on('error', e => resolve({ success: false, error: e.message })); + req.write(body); + req.end(); + }); +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +// POST /reminders — schedule a new reminder +app.post('/reminders', (req, res) => { + const { message, sendAt, to, label } = req.body; + + if (!message || !sendAt) { + return res.status(400).json({ error: 'message and sendAt are required' }); + } + + const sendAtDate = new Date(sendAt); + if (isNaN(sendAtDate.getTime())) { + return res.status(400).json({ error: 'sendAt is not a valid ISO date string' }); + } + if (sendAtDate <= new Date()) { + return res.status(400).json({ error: 'sendAt must be in the future' }); + } + + const reminders = load(); + const id = `rem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + + reminders.push({ + id, + message, + sendAt: sendAtDate.toISOString(), + to: to || PERSONAL, + label: label || message.slice(0, 60), + status: 'pending', + createdAt: new Date().toISOString(), + }); + + save(reminders); + console.log(`[Reminder] Scheduled ${id}: "${label || message.slice(0, 40)}" → ${sendAtDate.toISOString()}`); + res.json({ success: true, id, sendAt: sendAtDate.toISOString() }); +}); + +// POST /reminders/batch — schedule multiple reminders at once +app.post('/reminders/batch', (req, res) => { + const { items } = req.body; // [{ message, sendAt, to, label }] + if (!Array.isArray(items) || items.length === 0) { + return res.status(400).json({ error: 'items array is required' }); + } + + const reminders = load(); + const created = []; + const errors = []; + + for (const item of items) { + const { message, sendAt, to, label } = item; + if (!message || !sendAt) { errors.push({ item, error: 'missing fields' }); continue; } + const sendAtDate = new Date(sendAt); + if (isNaN(sendAtDate.getTime()) || sendAtDate <= new Date()) { + errors.push({ item, error: 'invalid or past sendAt' }); continue; + } + const id = `rem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + reminders.push({ + id, + message, + sendAt: sendAtDate.toISOString(), + to: to || PERSONAL, + label: label || message.slice(0, 60), + status: 'pending', + createdAt: new Date().toISOString(), + }); + created.push({ id, sendAt: sendAtDate.toISOString(), label: label || message.slice(0, 40) }); + } + + save(reminders); + console.log(`[Reminder] Batch: ${created.length} scheduled, ${errors.length} failed`); + res.json({ success: true, created, errors }); +}); + +// GET /reminders — list reminders +app.get('/reminders', (req, res) => { + const all = load(); + const status = req.query.status; // optional filter: pending|sent|cancelled|failed + const result = status ? all.filter(r => r.status === status) : all; + res.json(result.slice(-100)); // last 100 +}); + +// DELETE /reminders/:id — cancel a reminder +app.delete('/reminders/:id', (req, res) => { + const reminders = load(); + const idx = reminders.findIndex(r => r.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'not found' }); + if (reminders[idx].status !== 'pending') { + return res.status(409).json({ error: `Cannot cancel — status is "${reminders[idx].status}"` }); + } + reminders[idx].status = 'cancelled'; + save(reminders); + console.log(`[Reminder] Cancelled ${req.params.id}`); + res.json({ success: true }); +}); + +// GET /health +app.get('/health', (req, res) => res.json({ status: 'ok', time: new Date().toISOString() })); + +// ── Scheduler (every minute) ────────────────────────────────────────────────── + +cron.schedule('* * * * *', async () => { + const now = new Date(); + const reminders = load(); + let changed = false; + + for (const r of reminders) { + if (r.status !== 'pending') continue; + if (new Date(r.sendAt) > now) continue; + + console.log(`[Reminder] Firing ${r.id}: "${r.label}" → ${r.to}`); + const result = await sendWhatsApp(r.to, r.message); + + r.status = result.success ? 'sent' : 'failed'; + r.sentAt = now.toISOString(); + if (!result.success) r.error = result.error; + changed = true; + + console.log(`[Reminder] ${result.success ? '✅' : '❌'} ${r.id} ${result.success ? result.messageId : r.error}`); + } + + if (changed) save(reminders); +}); + +app.listen(PORT, () => { + console.log(`[ReminderService] Listening on :${PORT}`); + console.log(`[ReminderService] Data: ${DATA_FILE} | Gateway: ${GATEWAY}`); + const pending = load().filter(r => r.status === 'pending').length; + console.log(`[ReminderService] Loaded ${pending} pending reminder(s).`); +});