247 lines
10 KiB
JavaScript
247 lines
10 KiB
JavaScript
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');
|
|
|
|
let timezone = profile.timezone || this.fallbackTimezone;
|
|
let clock = localClock(now, timezone);
|
|
if (!clock && timezone !== this.fallbackTimezone) {
|
|
timezone = this.fallbackTimezone;
|
|
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',
|
|
preferredLanguage: profile.language,
|
|
});
|
|
if (result.pendingAction || !result.notify) {
|
|
this._scheduleDynamic(now, runtime?.delta, false);
|
|
return this._result(false, result.pendingAction ? 'mutation_rejected' : 'agent_declined');
|
|
}
|
|
|
|
const german = profile.language === 'de';
|
|
const evidence = result.evidence?.length
|
|
? `\n\n${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 4).map(item => `- ${item}`).join('\n')}`
|
|
: '';
|
|
const sent = await this.alerter.sendMessage(`[DAVE // ${german ? 'AKTIV' : '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;
|
|
}
|