Files
intelligence-terminal/lib/agent/dave-presence.mjs
MrSphay a7f3f36d2b
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 8s
Build / test-and-image (pull_request) Successful in 52s
feat: make DAVE Telegram chat conversational
2026-07-07 18:47:17 +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 one short chat-style message without visible Markdown, headings, tables, or report sections. 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, 4).map(item => `- ${item}`).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: 800 })
: 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;
}