Files
intelligence-terminal/lib/agent/dave-presence.mjs

241 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');
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;
}