import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { isWithinQuietHours } from '../security/security-alert-policy.mjs'; export class DavePresence { constructor({ agent, alerter, profileStore, getContext, getRuntime, statePath, config = {}, random = Math.random } = {}) { this.agent = agent; this.alerter = alerter; this.profileStore = profileStore; this.getContext = getContext || (() => ''); this.getRuntime = getRuntime || (() => ({})); this.statePath = statePath; this.random = random; this.enabled = Boolean(config.enabled); this.maxPerDay = boundedInt(config.maxPerDay, 1, 8, 4); this.minGapMs = boundedInt(config.minGapMinutes, 15, 720, 75) * 60 * 1000; this.minIntervalMs = boundedInt(config.minIntervalMinutes, 15, 360, 45) * 60 * 1000; this.maxIntervalMs = boundedInt(config.maxIntervalMinutes, 30, 720, 180) * 60 * 1000; if (this.maxIntervalMs < this.minIntervalMs) this.maxIntervalMs = this.minIntervalMs; this.idleAfterMs = boundedInt(config.idleAfterMinutes, 15, 720, 60) * 60 * 1000; this.checkIntervalMs = boundedInt(config.checkIntervalMinutes, 1, 60, 5) * 60 * 1000; this.fallbackTimezone = String(config.timezone || 'Europe/Berlin'); this.state = loadState(statePath); this.inFlight = false; this.timer = null; this.startupTimer = null; this.lastReason = this.enabled ? 'waiting' : 'disabled'; } start(now = new Date()) { if (!this.enabled || this.timer) return false; if (!this.state.nextEvaluationAt) this._schedule(now, randomBetween(this.minIntervalMs, this.maxIntervalMs, this.random)); const run = () => this.tick().catch(error => { this.lastReason = `failed: ${String(error.message || error).slice(0, 160)}`; console.error('[DAVE Presence] Evaluation failed:', error.message); }); this.startupTimer = setTimeout(run, 30_000); this.startupTimer.unref?.(); this.timer = setInterval(run, this.checkIntervalMs); this.timer.unref?.(); return true; } stop() { if (this.startupTimer) clearTimeout(this.startupTimer); if (this.timer) clearInterval(this.timer); this.startupTimer = null; this.timer = null; } noteUserInteraction(now = new Date()) { this.state.lastUserInteractionAt = now.toISOString(); const earliest = now.getTime() + this.idleAfterMs; if (!this.state.nextEvaluationAt || new Date(this.state.nextEvaluationAt).getTime() < earliest) { this.state.nextEvaluationAt = new Date(earliest).toISOString(); } this._save(); } nudge(delta, now = new Date()) { if (!this.enabled) return false; const critical = Number(delta?.summary?.criticalChanges || 0); const total = Number(delta?.summary?.totalChanges || 0); if (critical <= 0 && total < 3) return false; const delay = critical > 0 ? 5 * 60 * 1000 : 20 * 60 * 1000; const candidate = now.getTime() + delay; if (!this.state.nextEvaluationAt || candidate < new Date(this.state.nextEvaluationAt).getTime()) { this.state.nextEvaluationAt = new Date(candidate).toISOString(); this._save(); } return true; } async tick(now = new Date()) { if (!this.enabled) return this._result(false, 'disabled'); if (this.inFlight) return this._result(false, 'in_flight'); if (!this.agent?.isConfigured || !this.alerter?.isConfigured) return this._result(false, 'unavailable'); const profile = this.profileStore?.getAgentProfile(); if (!profile) return this._result(false, 'profile_missing'); const timezone = profile.timezone || this.fallbackTimezone; const clock = localClock(now, timezone); if (!clock) return this._result(false, 'invalid_timezone'); this._rollDay(clock.day); if (this.state.sentCount >= this.maxPerDay) return this._result(false, 'daily_limit'); const dueAt = new Date(this.state.nextEvaluationAt || 0).getTime(); if (Number.isFinite(dueAt) && dueAt > now.getTime()) return this._result(false, 'not_due'); if (isWithinQuietHours(profile.quietHours, timezone, now)) { this._schedule(now, Math.min(this.maxIntervalMs, 30 * 60 * 1000)); return this._result(false, 'quiet_hours'); } if (this.state.lastSentAt && now.getTime() - new Date(this.state.lastSentAt).getTime() < this.minGapMs) { this._scheduleAt(new Date(this.state.lastSentAt).getTime() + this.minGapMs); return this._result(false, 'minimum_gap'); } if (this.state.lastUserInteractionAt && now.getTime() - new Date(this.state.lastUserInteractionAt).getTime() < this.idleAfterMs) { this._scheduleAt(new Date(this.state.lastUserInteractionAt).getTime() + this.idleAfterMs); return this._result(false, 'operator_active'); } this.inFlight = true; this.state.lastEvaluationAt = now.toISOString(); this._save(); try { const runtime = this.getRuntime(); const prompt = `Decide whether DAVE should initiate a natural, useful conversation with the operator now. This is a dynamic presence evaluation, not a fixed scheduled briefing. Inspect the security profile and current terminal intelligence using only read-only tools. Consider freshness, source integrity, recent sweep changes, evidence, scenarios, personal relevance, time since interaction, and whether there is something genuinely useful to say. You may provide a concise relevant warning, situational update, evidence-grounded all-clear, practical suggestion, or one natural question that improves protection or context. Set notify=false when speaking would add noise. Never call mutating tools. Never imply consciousness, feelings, continuous observation, or activity outside this evaluation. Local time: ${clock.time}; timezone: ${timezone}; sent today: ${this.state.sentCount}/${this.maxPerDay}; last operator interaction: ${this.state.lastUserInteractionAt || 'unknown'}; last DAVE message: ${this.state.lastSentAt || 'none'}.`; const result = await this.agent.run(prompt, { chatId: 'dave-presence', context: String(await this.getContext()).slice(0, 12000), runtime, mode: 'presence', }); if (result.pendingAction || !result.notify) { this._scheduleDynamic(now, runtime?.delta, false); return this._result(false, result.pendingAction ? 'mutation_rejected' : 'agent_declined'); } const evidence = result.evidence?.length ? `\n\nEvidence:\n${result.evidence.slice(0, 4).map(item => `- ${item}`).join('\n')}` : ''; const sent = await this.alerter.sendMessage(`[DAVE // ACTIVE]\n${result.answer}${evidence}`, { parseMode: null }); if (!sent?.ok && sent !== true) { this._schedule(now, this.minIntervalMs); return this._result(false, 'send_failed'); } this.state.sentCount++; this.state.lastSentAt = now.toISOString(); this._scheduleDynamic(now, runtime?.delta, true); return this._result(true, 'sent'); } finally { this.inFlight = false; } } status() { return { enabled: this.enabled, running: Boolean(this.timer), dynamic: true, maxPerDay: this.maxPerDay, sentToday: this.state.sentCount || 0, lastSentAt: this.state.lastSentAt || null, lastEvaluationAt: this.state.lastEvaluationAt || null, nextEvaluationAt: this.state.nextEvaluationAt || null, lastUserInteractionAt: this.state.lastUserInteractionAt || null, lastReason: this.lastReason, }; } _rollDay(day) { if (this.state.day === day) return; this.state = { ...this.state, day, sentCount: 0 }; this._save(); } _scheduleDynamic(now, delta, sent) { const critical = Number(delta?.summary?.criticalChanges || 0); const total = Number(delta?.summary?.totalChanges || 0); let min = this.minIntervalMs; let max = this.maxIntervalMs; if (critical > 0) max = Math.min(max, min * 1.5); else if (total >= 3) max = Math.min(max, min * 2); else if (!sent) min = Math.min(max, min * 1.5); this._schedule(now, randomBetween(min, max, this.random)); } _schedule(now, delayMs) { this._scheduleAt(now.getTime() + Math.max(60_000, delayMs)); } _scheduleAt(timestamp) { this.state.nextEvaluationAt = new Date(timestamp).toISOString(); this._save(); } _save() { if (!this.statePath) return; mkdirSync(dirname(this.statePath), { recursive: true }); const temporaryPath = `${this.statePath}.tmp`; writeFileSync(temporaryPath, JSON.stringify(this.state), 'utf8'); renameSync(temporaryPath, this.statePath); } _result(sent, reason) { this.lastReason = reason; return { sent, reason }; } } export function localClock(date, timezone) { try { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(date); const value = type => parts.find(part => part.type === type)?.value; const hour = Number(value('hour')); const minute = Number(value('minute')); return { day: `${value('year')}-${value('month')}-${value('day')}`, time: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`, }; } catch { return null; } } function loadState(path) { const empty = { day: null, sentCount: 0, lastSentAt: null, lastEvaluationAt: null, nextEvaluationAt: null, lastUserInteractionAt: null }; if (!path || !existsSync(path)) return empty; try { const value = JSON.parse(readFileSync(path, 'utf8')); return { day: typeof value.day === 'string' ? value.day : null, sentCount: boundedInt(value.sentCount, 0, 8, 0), lastSentAt: validIso(value.lastSentAt), lastEvaluationAt: validIso(value.lastEvaluationAt), nextEvaluationAt: validIso(value.nextEvaluationAt), lastUserInteractionAt: validIso(value.lastUserInteractionAt), }; } catch { return empty; } } function randomBetween(min, max, random) { return Math.round(min + Math.max(0, Math.min(1, Number(random()) || 0)) * (max - min)); } function validIso(value) { const date = new Date(value); return value && Number.isFinite(date.getTime()) ? date.toISOString() : null; } function boundedInt(value, min, max, fallback) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback; }