Files
intelligence-terminal/lib/agent/dave-presence.mjs
MrSphay 1178146d59
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 32s
Build / test-and-image (pull_request) Successful in 2m4s
fix: make dave telegram alerts conversational
2026-07-11 10:58:37 +02:00

250 lines
11 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, including web_search only when current external corroboration is necessary. 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. If notifying, write like a human Telegram contact: one short thought, one optional follow-up question, no visible Markdown, no headings, no lists, no report sections, and keep the final answer below 420 characters when possible. 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
? `${german ? 'Belege' : 'Evidence'}:\n${result.evidence.slice(0, 2).map(item => `- ${String(item).slice(0, 120)}`).join('\n')}`
: '';
const messages = [`[DAVE // ${german ? 'AKTIV' : 'ACTIVE'}]\n${result.answer}`, evidence].filter(Boolean);
const sent = typeof this.alerter.sendConversation === 'function'
? await this.alerter.sendConversation(messages, { maxChunkChars: 380 })
: await this.alerter.sendMessage(messages.join('\n\n'), { 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;
}