2026-07-05 22:47:08 +02:00
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' ) ;
2026-07-05 23:01:37 +02:00
let timezone = profile . timezone || this . fallbackTimezone ;
let clock = localClock ( now , timezone ) ;
if ( ! clock && timezone !== this . fallbackTimezone ) {
timezone = this . fallbackTimezone ;
clock = localClock ( now , timezone ) ;
}
2026-07-05 22:47:08 +02:00
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' ,
2026-07-06 09:14:55 +02:00
preferredLanguage : profile . language ,
2026-07-05 22:47:08 +02:00
} ) ;
if ( result . pendingAction || ! result . notify ) {
this . _scheduleDynamic ( now , runtime ? . delta , false ) ;
return this . _result ( false , result . pendingAction ? 'mutation_rejected' : 'agent_declined' ) ;
}
2026-07-06 09:14:55 +02:00
const german = profile . language === 'de' ;
2026-07-05 22:47:08 +02:00
const evidence = result . evidence ? . length
2026-07-06 09:14:55 +02:00
? ` \n \n ${ german ? 'Belege' : 'Evidence' } : \n ${ result . evidence . slice ( 0 , 4 ) . map ( item => ` - ${ item } ` ) . join ( '\n' ) } `
2026-07-05 22:47:08 +02:00
: '' ;
2026-07-06 09:14:55 +02:00
const sent = await this . alerter . sendMessage ( ` [DAVE // ${ german ? 'AKTIV' : 'ACTIVE' } ] \n ${ result . answer } ${ evidence } ` , { parseMode : null } ) ;
2026-07-05 22:47:08 +02:00
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 ;
}